@link-assistant/hive-mind 2.17.0 → 2.19.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/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ac07514: Stop the model list from ageing with the release: add the models that shipped since 2.17.0, hot-load the live catalogue from every source that costs nothing to read, expose it as `/models` and `hive-models`, teach `--use-router` both of the router's route dialects, and check the agentic CLI for a newer version before a task starts (issue #2202).
8
+
9
+ A provider can ship a model on a Tuesday, and until now Hive Mind could not name it until the next release: `src/models/index.mjs` was a static table and `--model` validated against nothing else. Six changes close that gap, none of which can spend a token or fail a run.
10
+
11
+ - **The new models are in the bundled table.** Fable 5.1, Mythos 5.1, GPT-6 Astra and GPT-5.6 Cyber, with their aliases, context windows and fallback chains. The additions pushed `src/models/index.mjs` past the 1350-line early warning of `scripts/check-file-line-limits.sh`, so the catalogue itself moved to `src/models/catalog.mjs` and is re-exported — the public surface is unchanged.
12
+ - **The catalogue hot-loads.** `src/model-catalogue.lib.mjs` merges six sources in a fixed precedence: the Link.Assistant Router's live catalogue, `codex debug models`, Anthropic's and OpenAI's `GET /v1/models`, models.dev for specifications, and the bundled table as the offline floor. models.dev only annotates; it never adds a model to the available list. Answers are cached per source and per tool for at least an hour, and `HIVE_MIND_MODEL_CATALOGUE_TTL_MINUTES` can only raise that floor.
13
+ - **Listing models cannot cost a token, structurally.** `assertTokenFreeSource` rejects any source that has not explicitly declared `billable: false`, so a source cannot become billable by omission, and `assertTokenFreeUrl` rejects any URL whose path is a completion endpoint no matter which descriptor carried it — a typo turning `/v1/models` into `/v1/messages` throws instead of spending. The four extraction methods that were considered and rejected, including driving either CLI's `/model` picker through its TUI, are recorded with their reasons so a future contributor does not re-derive them.
14
+ - **`/models` and `hive-models` show the whole spectrum.** Models are grouped as bundled-and-live, hot-loaded (a live source has it, this installation does not ship it), and bundled-only (shipped, unconfirmed), with `--tool`, `--details`, `--refresh`, `--json`, and `--all`. A footer names every source actually consulted, its outcome, and the age of the cache, so "why don't I see X" is answerable from the output — and a source that failed is described from its stderr, never by quoting the command line, so the router's leased token cannot ride a failure message into the footer. In Telegram the same flags are accepted the way a chat writes them: `/models codex`, `/models --tool codex` and `/models --tool=codex` are one command.
15
+ - **`--use-router` speaks both route dialects.** Router 1.0 removed every root, `/v1/*` and overlapping `/api/*` alias and moved each service under `/api/services/<service>/…` — measured, not assumed: on `1.2.0` every path that answers on `0.119.0` is a 404 and vice versa. `src/router-routes.lib.mjs` derives every base URL, the health probe and the catalogue path from the pinned image, so the pin can move in one line. It stays on `0.x` today, at `0.125.4`, because router `1.x` has no `gh`-reachable REST base and `gh` exposes no path-prefix option; the trade is reported by `describeRouterCoverageGaps` rather than discovered at runtime, and the upstream issues are filed.
16
+ - **A stale CLI is refreshed before the run needs it.** `/solve`, `/hive`, `/task`, `/fix`, `/models` and `hive-models` check the agentic CLI they are about to drive, because an outdated binary is the usual reason a brand-new model name is rejected. The check is throttled to once every six hours, narrowed to the one CLI in play, deferred entirely while other tasks are running, skipped for `--dry-run` and `--only-prepare-command`, and never fatal. Each run excludes its own task from the idle gate — otherwise a `/solve` asking for an update would find itself in the process table and defer forever. Opt out with `--no-tool-update` or `HIVE_MIND_AGENTIC_CLI_AUTO_UPDATE=0`.
17
+
18
+ Documentation is in `docs/MODELS.md` and its `zh`/`hi`/`ru` siblings; the full analysis — the requirement-by-requirement plan, the router route measurement, and what shipped differently from the plan — is in `docs/case-studies/issue-2202/`.
19
+
20
+ ## 2.18.0
21
+
22
+ ### Minor Changes
23
+
24
+ - 3fc12ef: Recover broken GitHub URLs instead of rejecting them, and say what was repaired (issue #2194).
25
+
26
+ A user sent `/Claude https://github.com/G-Ivan-A/aether-orbis/pulls/30`. Telegram drew a healthy GitHub preview card under it, because github.com really does answer `/owner/repo/pulls/30` with **HTTP 200** and a full set of Open Graph tags — a genuinely wrong path like `/pullz/30` 404s and gets no card. The bot then refused the command and told the user to "1. Open the repository: https://github.com/G-Ivan-A/aether-orbis/**pulls/30**" — the same broken link that had just failed. Meanwhile `parseGitHubUrl` had already extracted the number: the pre-fix `case 'pulls'` stored `'30'` in `result.subpath` and nothing ever read it. The data to restore the URL from was sitting in the result object.
27
+
28
+ - **`src/github-url-recovery.lib.mjs`** repairs a URL before it is parsed: strips invisible characters (`\p{Cf}`/`\p{Cc}`/`\p{Zl}`/`\p{Zp}`, U+034F, variation selectors), unwraps `[title](url)` / `<url>` / `(url).`, folds full-width and fraction punctuation and full-width digits to ASCII, lower-cases scheme and host, reads `git@github.com:owner/repo.git` and `api.github.com/repos/...` as their web addresses, and reads `/pulls/30` as `/pull/30`, `/issue/123` as `/issues/123`, `/pull/30/files` as the pull request itself.
29
+ - **Recovery is on by default inside `parseGitHubUrl`**, so all 48 call sites across 14 files get it without changing any of them. `{ recover: false }` reproduces the old code path exactly, which is what makes the before/after evidence reproducible.
30
+ - **It never invents a GitHub URL.** `gitlab.com`, `bitbucket.org`, `gist.github.com`, `raw.githubusercontent.com`, `github.com.evil.example` and `evil.example/github.com/...` are all still rejected — and so is over-reach in the other direction: `support@github.com` is an email address rather than a repository (`git@github.com:owner/repo.git` still works), and `[the PR](aether-orbis)` stays rejected because unwrapping prose is only worth doing when what comes out already names github.com.
31
+ - **It never repairs silently.** Every result carries `original`, `repairs[]` and `recovered`, plus `hidden`/`revealed` when something invisible was removed. The Telegram bot (new `telegram.url_recovered` string in `en`/`ru`/`hi`/`zh`), `solve` and `hive` each print what they understood before acting on it.
32
+ - **Two silent-corruption bugs went with it.** A zero-width space in a repository name used to produce `valid: true` for `aether-orbis%E2%80%8B` — a repository that does not exist. `HTTPS://GITHUB.COM/...` used to parse as a _relative path_ with `HTTPS:` as the owner; the bot's own `url.includes('github.com')` gate had the same flaw and rejected the URL before the parser saw it — that gate now asks the recovery layer whether the text names github.com as its _host_, so `evil.example/github.com/...` no longer passes it either.
33
+ - **The log could not have proved an invisible character even if there had been one** — it never recorded the message text. `/solve` and its aliases now log raw text through `revealHiddenCharacters()` (a zero-width space appears as `[U+200B]`), and `traceUrlRecovery()` reports each repair stage under `--verbose`.
34
+
35
+ `normalize-url@9` and `confusables@1` were installed and measured against these exact inputs rather than assumed: `normalize-url` percent-encodes the zero-width space instead of removing it and **throws** on a full-width colon, and `confusables` turns `github.com/Ćwikła/...` into `github.com/Cwikla/...` — it would silently retarget a real owner. Neither knows that `/pulls/30` means pull request 30.
36
+
37
+ 77 assertions in `tests/test-issue-2194-broken-url-recovery.mjs`. Timeline, evidence and the full analysis are in `docs/case-studies/issue-2194/README.md`, with reproduction scripts in `experiments/issue-2194/` and `examples/github-url-recovery-demo.mjs`.
38
+
3
39
  ## 2.17.0
4
40
 
5
41
  ### Minor Changes
package/README.hi.md CHANGED
@@ -515,6 +515,8 @@ Free Models via Kilo Gateway (with --tool agent):
515
515
 
516
516
  > **📖 मुफ्त मॉडल गाइड**: OpenCode Zen और Kilo Gateway प्रदाताओं सहित सभी मुफ्त मॉडलों के बारे में व्यापक जानकारी के लिए [docs/FREE_MODELS.hi.md](./docs/FREE_MODELS.hi.md) देखें।
517
517
 
518
+ > **📖 लाइव मॉडल सूची**: यह देखने के लिए कि इस समय कौन-से मॉडल उपलब्ध हैं — उनमें वे भी जो इस installation के प्रकाशित होने के बाद जारी हुए — `hive-models` चलाएँ (या Telegram में `/models`)। देखें [docs/MODELS.hi.md](./docs/MODELS.hi.md)।
519
+
518
520
  #### `/hive` - Hive ऑर्केस्ट्रेशन चलाएँ
519
521
 
520
522
  ```
package/README.md CHANGED
@@ -533,6 +533,8 @@ See [docs/CONFIGURATION.md](./docs/CONFIGURATION.md) for the full per-tool defau
533
533
 
534
534
  > **📖 Free Models Guide**: See [docs/FREE_MODELS.md](./docs/FREE_MODELS.md) for comprehensive information about all free models including OpenCode Zen and Kilo Gateway providers.
535
535
 
536
+ > **📖 Live model list**: run `hive-models` (or `/models` in Telegram) to see which models are reachable right now, including ones released after this installation was published. See [docs/MODELS.md](./docs/MODELS.md).
537
+
536
538
  #### `/hive` - Run Hive Orchestration
537
539
 
538
540
  ```
package/README.ru.md CHANGED
@@ -516,6 +516,8 @@ Free Models via Kilo Gateway (with --tool agent):
516
516
 
517
517
  > **📖 Руководство по бесплатным моделям**: см. [docs/FREE_MODELS.ru.md](./docs/FREE_MODELS.ru.md) для получения полной информации обо всех бесплатных моделях, включая провайдеры OpenCode Zen и Kilo Gateway.
518
518
 
519
+ > **📖 Живой список моделей**: запустите `hive-models` (или `/models` в Telegram), чтобы увидеть, какие модели доступны прямо сейчас, включая вышедшие уже после публикации этой установки. См. [docs/MODELS.ru.md](./docs/MODELS.ru.md).
520
+
519
521
  #### `/hive` — Запуск оркестрации Hive
520
522
 
521
523
  ```
package/README.zh.md CHANGED
@@ -512,6 +512,8 @@ Free Models via Kilo Gateway (with --tool agent):
512
512
 
513
513
  > **📖 免费模型指南**:有关所有免费模型(包括 OpenCode Zen 和 Kilo Gateway 提供商)的全面信息,请参见 [docs/FREE_MODELS.zh.md](./docs/FREE_MODELS.zh.md)。
514
514
 
515
+ > **📖 实时模型列表**:运行 `hive-models`(或在 Telegram 中使用 `/models`)即可查看当前可用的模型,包括本次安装发布之后才推出的模型。参见 [docs/MODELS.zh.md](./docs/MODELS.zh.md)。
516
+
515
517
  #### `/hive` - 运行蜂群编排
516
518
 
517
519
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  "configure-claude": "./src/configure-claude.mjs",
15
15
  "start-screen": "./src/start-screen.mjs",
16
16
  "hive-screens": "./src/hive-screens.mjs",
17
+ "hive-models": "./src/hive-models.mjs",
17
18
  "hive-telegram-bot": "./src/telegram-bot.mjs"
18
19
  },
19
20
  "scripts": {
@@ -30,7 +31,7 @@
30
31
  "changeset": "changeset",
31
32
  "changeset:version": "changeset version",
32
33
  "changeset:publish": "npm run build:pre && changeset publish",
33
- "build:pre": "chmod +x src/hive.mjs && chmod +x src/solve.mjs && chmod +x src/task.mjs && chmod +x src/fix.mjs && chmod +x src/cleanup.mjs && chmod +x src/review.mjs && chmod +x src/configure-claude.mjs && chmod +x src/start-screen.mjs && chmod +x src/hive-screens.mjs && chmod +x src/telegram-bot.mjs",
34
+ "build:pre": "chmod +x src/hive.mjs && chmod +x src/solve.mjs && chmod +x src/task.mjs && chmod +x src/fix.mjs && chmod +x src/cleanup.mjs && chmod +x src/review.mjs && chmod +x src/configure-claude.mjs && chmod +x src/start-screen.mjs && chmod +x src/hive-screens.mjs && chmod +x src/hive-models.mjs && chmod +x src/telegram-bot.mjs",
34
35
  "prepare": "husky"
35
36
  },
36
37
  "repository": {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Pre-flight CLI freshness check (issue #2202, R6).
3
+ *
4
+ * R6: "/models and each /solve and other commands that relevant for
5
+ * claude/codex tools should check if new version available, and before
6
+ * starting task execution or before providing new models list - we should
7
+ * update them."
8
+ *
9
+ * `updateAgenticClisWhenIdle` already knows how to do the refresh safely — it
10
+ * throttles registry reads, takes a state lock, and refuses to swap a binary
11
+ * out from under a running task. What it lacked was a caller other than the
12
+ * Telegram maintenance tick, and two things a command entry point needs:
13
+ *
14
+ * 1. **Narrowing.** `hive-models --tool codex` should not reinstall Gemini.
15
+ * 2. **Not counting itself as busy.** The idle gate scans `/proc` for running
16
+ * solve/task processes by issue reference. A solve run that checks for
17
+ * updates after it has started would find *itself* and defer forever, so
18
+ * the caller passes its own task reference to be ignored.
19
+ *
20
+ * Everything here is best-effort: a refresh failure must never stop the command
21
+ * the operator actually asked for.
22
+ *
23
+ * @see https://github.com/link-assistant/hive-mind/issues/2202
24
+ */
25
+
26
+ import { AGENTIC_CLI_TARGETS, isAgenticCliAutoUpdateEnabled, updateAgenticClisWhenIdle } from './agentic-cli-updater.lib.mjs';
27
+
28
+ const KNOWN_TOOL_IDS = new Set(AGENTIC_CLI_TARGETS.map(target => target.id));
29
+
30
+ /** Tool aliases Hive Mind commands use that are not the updater's target id. */
31
+ export const FRESHNESS_TOOL_ALIASES = Object.freeze({
32
+ 'claude-code': 'claude',
33
+ 'gemini-cli': 'gemini',
34
+ 'qwen-code': 'qwen',
35
+ 'github-copilot': 'copilot',
36
+ 'opencode-ai': 'opencode',
37
+ });
38
+
39
+ /** Normalize whatever a command calls its tool into updater target ids. */
40
+ export const resolveFreshnessTools = tools => {
41
+ const requested = (Array.isArray(tools) ? tools : [tools])
42
+ .flatMap(entry =>
43
+ String(entry ?? '')
44
+ .split(',')
45
+ .map(part => part.trim().toLowerCase())
46
+ )
47
+ .filter(Boolean);
48
+ const resolved = [];
49
+ for (const entry of requested) {
50
+ const id = FRESHNESS_TOOL_ALIASES[entry] ?? entry;
51
+ if (KNOWN_TOOL_IDS.has(id) && !resolved.includes(id)) resolved.push(id);
52
+ }
53
+ return resolved;
54
+ };
55
+
56
+ /** `https://github.com/o/r/issues/7` → `{owner:'o', repo:'r', number:7}`. */
57
+ export const parseTaskRef = value => {
58
+ if (!value) return null;
59
+ if (typeof value === 'object') {
60
+ const number = Number(value.number);
61
+ if (!value.owner || !value.repo || !Number.isFinite(number)) return null;
62
+ return { owner: String(value.owner), repo: String(value.repo), number };
63
+ }
64
+ const match = String(value).match(/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/i);
65
+ if (!match) return null;
66
+ return { owner: match[1], repo: match[2], number: Number(match[3]) };
67
+ };
68
+
69
+ const sameRef = (a, b) => a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() && a.number === b.number;
70
+
71
+ /**
72
+ * Refresh the agentic CLIs a command is about to drive.
73
+ *
74
+ * Never throws: every failure path returns a status the caller can log and
75
+ * ignore. Statuses come from `updateAgenticClisWhenIdle`
76
+ * (`checked`/`throttled`/`busy`/`disabled`) plus `skipped` for a caller opt-out
77
+ * and `error` when the refresh itself blew up.
78
+ *
79
+ * @param {object} options
80
+ * @param {string|string[]} options.tools tool ids/aliases the command needs
81
+ * @param {boolean} options.enabled false for `--no-update`
82
+ * @param {Array} options.ignoreTasks task refs or GitHub URLs that are *this* run
83
+ */
84
+ export const ensureAgenticCliFreshness = async ({ tools = [], env = process.env, log = null, verbose = false, force = false, enabled = true, ignoreTasks = [], getActiveTasksImpl = null, updateImpl = updateAgenticClisWhenIdle, minIntervalMs = undefined } = {}) => {
85
+ const only = resolveFreshnessTools(tools);
86
+ if (!enabled) return { status: 'skipped', reason: 'the caller disabled the update check (--no-update)', tools: only, updated: [], upToDate: [], failed: [] };
87
+ if (!isAgenticCliAutoUpdateEnabled(env)) return { status: 'disabled', reason: 'HIVE_MIND_AGENTIC_CLI_AUTO_UPDATE is off', tools: only, updated: [], upToDate: [], failed: [] };
88
+
89
+ const ignored = ignoreTasks.map(parseTaskRef).filter(Boolean);
90
+ let activeTasks = getActiveTasksImpl;
91
+ if (ignored.length > 0) {
92
+ const inner = getActiveTasksImpl ?? (await import('./cleanup.os.lib.mjs')).getActiveTasks;
93
+ activeTasks = async options => {
94
+ const tasks = await inner(options);
95
+ return tasks.filter(task => !ignored.some(ref => sameRef(ref, { owner: task.owner, repo: task.repo, number: Number(task.number) })));
96
+ };
97
+ }
98
+
99
+ try {
100
+ const result = await updateImpl({ env, log, verbose, force, only, ...(activeTasks ? { getActiveTasksImpl: activeTasks } : {}), ...(minIntervalMs === undefined ? {} : { minIntervalMs }) });
101
+ return { tools: only, updated: [], upToDate: [], failed: [], ...result };
102
+ } catch (error) {
103
+ const message = String(error?.message ?? error);
104
+ if (verbose && log) await log(`[VERBOSE] agentic-cli-freshness: refresh failed — ${message}`);
105
+ return { status: 'error', reason: message, tools: only, updated: [], upToDate: [], failed: [] };
106
+ }
107
+ };
108
+
109
+ /** One human-readable line summarising a freshness result, or null when there is nothing to say. */
110
+ export const describeFreshnessResult = result => {
111
+ if (!result) return null;
112
+ if (result.updated?.length > 0) return `⬆️ Updated ${result.updated.map(entry => `${entry.id} ${entry.from} → ${entry.to}`).join(', ')}`;
113
+ if (result.failed?.length > 0) return `⚠️ Could not update ${result.failed.map(entry => entry.id).join(', ')}`;
114
+ if (result.status === 'busy') return 'Skipped the CLI update check: other tasks are running.';
115
+ return null;
116
+ };
117
+
118
+ export default { FRESHNESS_TOOL_ALIASES, describeFreshnessResult, ensureAgenticCliFreshness, parseTaskRef, resolveFreshnessTools };
@@ -81,10 +81,14 @@ const parseIdList = value =>
81
81
  * `HIVE_MIND_AGENTIC_CLI_UPDATE_ONLY` is an allow-list and
82
82
  * `HIVE_MIND_AGENTIC_CLI_UPDATE_EXCLUDE` a deny-list, both by target id.
83
83
  */
84
- export const listAgenticCliUpdateTargets = (env = process.env) => {
84
+ export const listAgenticCliUpdateTargets = (env = process.env, { only: requested = [] } = {}) => {
85
85
  const only = parseIdList(env.HIVE_MIND_AGENTIC_CLI_UPDATE_ONLY);
86
86
  const excluded = new Set(parseIdList(env.HIVE_MIND_AGENTIC_CLI_UPDATE_EXCLUDE));
87
- return AGENTIC_CLI_TARGETS.filter(target => (only.length === 0 || only.includes(target.id)) && !excluded.has(target.id));
87
+ // A caller-supplied narrowing (issue #2202, R6: refresh the CLIs a command is
88
+ // about to drive) intersects with the operator's allow-list rather than
89
+ // overriding it — an operator who excluded a CLI still gets it excluded.
90
+ const caller = parseIdList(Array.isArray(requested) ? requested.join(',') : requested);
91
+ return AGENTIC_CLI_TARGETS.filter(target => (only.length === 0 || only.includes(target.id)) && (caller.length === 0 || caller.includes(target.id)) && !excluded.has(target.id));
88
92
  };
89
93
 
90
94
  /** First semantic version in a CLI's `--version` output, which is rarely bare. */
@@ -147,7 +151,7 @@ export const installAgenticCli = async (target, { run = execFileAsync, timeoutMs
147
151
  *
148
152
  * @returns {Promise<{status: 'disabled'|'busy'|'throttled'|'checked', updated: object[], upToDate: object[], failed: object[]}>}
149
153
  */
150
- export const updateAgenticClisWhenIdle = async ({ env = process.env, fsImpl = fs, run = execFileAsync, log = null, verbose = false, getActiveTasksImpl = null, now = () => new Date(), minIntervalMs = DEFAULT_CLI_UPDATE_INTERVAL_MS, force = false, lockOptions = {} } = {}) => {
154
+ export const updateAgenticClisWhenIdle = async ({ env = process.env, fsImpl = fs, run = execFileAsync, log = null, verbose = false, getActiveTasksImpl = null, now = () => new Date(), minIntervalMs = DEFAULT_CLI_UPDATE_INTERVAL_MS, force = false, only = [], lockOptions = {} } = {}) => {
151
155
  if (!isAgenticCliAutoUpdateEnabled(env)) {
152
156
  if (verbose && log) await log('[VERBOSE] agentic-cli-updater: disabled by HIVE_MIND_AGENTIC_CLI_AUTO_UPDATE');
153
157
  return { status: 'disabled', updated: [], upToDate: [], failed: [] };
@@ -176,7 +180,7 @@ export const updateAgenticClisWhenIdle = async ({ env = process.env, fsImpl = fs
176
180
  const failed = [];
177
181
  const tools = { ...state.tools };
178
182
 
179
- for (const target of listAgenticCliUpdateTargets(env)) {
183
+ for (const target of listAgenticCliUpdateTargets(env, { only })) {
180
184
  const installed = await readInstalledCliVersion(target, { run });
181
185
  if (!installed) {
182
186
  // Not installed on this host (image variants differ); nothing to refresh.
@@ -49,8 +49,24 @@ export const dockerOk = async (run, args, options) => {
49
49
  }
50
50
  };
51
51
 
52
+ /**
53
+ * Mask the secrets a `docker` argv carries.
54
+ *
55
+ * `execFile` builds a failed command's message as "Command failed: <argv…>",
56
+ * and the argvs assembled here carry real secrets: the router's
57
+ * `--env TOKEN_SECRET=…`, a leased `--env ROUTER_CATALOGUE_TOKEN=…`, a
58
+ * provider's `--api-key …`. stderr is preferred over that message precisely
59
+ * because it describes the failure without the command line, but stderr is
60
+ * empty when the process is killed on a timeout — so whatever survives is
61
+ * masked before anyone reads it.
62
+ */
63
+ const maskDockerArgvSecrets = text =>
64
+ String(text ?? '')
65
+ .replace(/(--env[= ])([A-Za-z_][A-Za-z0-9_]*)=(\S+)/g, '$1$2=***')
66
+ .replace(/(--(?:api-key|token|secret|password)[= ])(\S+)/g, '$1***');
67
+
52
68
  /** The message a failed `docker` invocation should be reported with. */
53
- export const dockerErrorMessage = error => error?.stderr?.toString?.().trim() || error?.message || String(error);
69
+ export const dockerErrorMessage = error => maskDockerArgvSecrets(error?.stderr?.toString?.().trim() || error?.message || String(error));
54
70
 
55
71
  /**
56
72
  * Inspect a container without treating "absent" as an error.
@@ -1,8 +1,37 @@
1
1
  import { reportError } from './sentry.lib.mjs';
2
+ import { describeHiddenCharacters, repairGitHubPathParts, repairGitHubUrlText, revealHiddenCharacters, traceUrlRecovery } from './github-url-recovery.lib.mjs';
3
+
4
+ /**
5
+ * Attach the issue #2194 recovery diagnostics to a parse result.
6
+ *
7
+ * `original` is always present so a caller can show the user what they actually
8
+ * sent; `hidden`/`revealed` only appear when there was something invisible to
9
+ * reveal, keeping the common result small.
10
+ *
11
+ * @param {Object} result - The result object to annotate (mutated and returned).
12
+ * @param {string} original - The URL exactly as it was passed in.
13
+ * @param {Array<{code: string, message: string, notable: boolean}>} repairs
14
+ * @param {Array<{escape: string, name: string}>} hidden
15
+ * @returns {Object} The same result object.
16
+ */
17
+ function withRecoveryDiagnostics(result, original, repairs, hidden) {
18
+ result.original = original;
19
+ result.repairs = repairs;
20
+ result.recovered = repairs.length > 0;
21
+ if (hidden.length > 0) {
22
+ result.hidden = hidden;
23
+ result.revealed = revealHiddenCharacters(original);
24
+ }
25
+ return result;
26
+ }
2
27
 
3
28
  /**
4
29
  * Universal GitHub URL parser that handles various formats
5
30
  * @param {string} url - The GitHub URL to parse
31
+ * @param {Object} [options] - Parsing options
32
+ * @param {boolean} [options.recover=true] - Repair recoverable damage (invisible
33
+ * Unicode, wrappers, look-alike punctuation, `/pulls/30` for `/pull/30`, …)
34
+ * before parsing. Pass `false` to see the URL exactly as it was typed.
6
35
  * @returns {Object} Parsed URL information including:
7
36
  * - valid: boolean indicating if the URL is valid
8
37
  * - normalized: the normalized URL (https://github.com/...), query/fragment kept
@@ -13,22 +42,41 @@ import { reportError } from './sentry.lib.mjs';
13
42
  * - number: issue/PR number (if applicable)
14
43
  * - path: additional path components
15
44
  * - error: error message if invalid
45
+ * - original: the URL exactly as it was passed in
46
+ * - repairs: the list of repairs recovery had to apply (issue #2194)
47
+ * - recovered: true when at least one repair was applied
48
+ * - hidden/revealed: codepoint diagnostics, present only when the input carried
49
+ * invisible or look-alike characters
16
50
  */
17
- export function parseGitHubUrl(url) {
51
+ export function parseGitHubUrl(url, options = {}) {
18
52
  if (!url || typeof url !== 'string') {
19
53
  return {
20
54
  valid: false,
21
55
  error: 'Invalid input: URL must be a non-empty string',
22
56
  };
23
57
  }
58
+ const { recover = true } = options;
59
+ // Issue #2194: a URL that renders correctly on screen can still be broken —
60
+ // `…/pulls/30` previews as a healthy page, a zero-width space is unprintable by
61
+ // definition. Repair what can be repaired first, and keep a record of it, so the
62
+ // user is told what was interpreted instead of being told "invalid URL".
63
+ const hidden = describeHiddenCharacters(url);
64
+ let repairs = [];
24
65
  // Trim whitespace and remove trailing slashes
25
66
  let normalizedUrl = url.trim().replace(/\/+$/, '');
67
+ if (recover) {
68
+ const repaired = repairGitHubUrlText(normalizedUrl);
69
+ repairs = repaired.repairs;
70
+ if (repaired.rejection) {
71
+ traceUrlRecovery('rejected', { original: url, error: repaired.rejection, repairs, hidden });
72
+ return withRecoveryDiagnostics({ valid: false, error: repaired.rejection }, url, repairs, hidden);
73
+ }
74
+ normalizedUrl = repaired.text.replace(/\/+$/, '');
75
+ if (repairs.length > 0) traceUrlRecovery('repaired-text', { original: url, repaired: normalizedUrl, repairs, hidden });
76
+ }
26
77
  // Check if this looks like a valid GitHub-related input Reject clearly invalid inputs (spaces in the URL, special chars at the start, etc.)
27
78
  if (/\s/.test(normalizedUrl) || /^[!@#$%^&*()[\]{}|\\:;"'<>,?`~]/.test(normalizedUrl)) {
28
- return {
29
- valid: false,
30
- error: 'Invalid GitHub URL format',
31
- };
79
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid GitHub URL format' }, url, repairs, hidden);
32
80
  }
33
81
  // Handle protocol normalization
34
82
  if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
@@ -40,10 +88,7 @@ export function parseGitHubUrl(url) {
40
88
  normalizedUrl = 'https://github.com/' + normalizedUrl;
41
89
  } else {
42
90
  // Has github.com somewhere but not at the start - likely malformed
43
- return {
44
- valid: false,
45
- error: 'Invalid GitHub URL format',
46
- };
91
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid GitHub URL format' }, url, repairs, hidden);
47
92
  }
48
93
  }
49
94
  // Convert http to https
@@ -56,11 +101,16 @@ export function parseGitHubUrl(url) {
56
101
  // Generate suggested URL by replacing backslashes with forward slashes
57
102
  const suggestedUrl = urlBeforeQueryAndHash.replace(/\\/g, '/');
58
103
  const urlAfterPath = normalizedUrl.substring(urlBeforeQueryAndHash.length);
59
- return {
60
- valid: false,
61
- error: 'Invalid character in URL: backslash (\\) is not allowed in URL paths',
62
- suggestion: suggestedUrl + urlAfterPath,
63
- };
104
+ return withRecoveryDiagnostics(
105
+ {
106
+ valid: false,
107
+ error: 'Invalid character in URL: backslash (\\) is not allowed in URL paths',
108
+ suggestion: suggestedUrl + urlAfterPath,
109
+ },
110
+ url,
111
+ repairs,
112
+ hidden
113
+ );
64
114
  }
65
115
  // Parse the URL
66
116
  let urlObj;
@@ -74,17 +124,11 @@ export function parseGitHubUrl(url) {
74
124
  url: normalizedUrl,
75
125
  });
76
126
  }
77
- return {
78
- valid: false,
79
- error: 'Invalid URL format',
80
- };
127
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid URL format' }, url, repairs, hidden);
81
128
  }
82
129
  // Ensure it's a GitHub URL
83
130
  if (urlObj.hostname !== 'github.com' && urlObj.hostname !== 'www.github.com') {
84
- return {
85
- valid: false,
86
- error: 'Not a GitHub URL',
87
- };
131
+ return withRecoveryDiagnostics({ valid: false, error: 'Not a GitHub URL' }, url, repairs, hidden);
88
132
  }
89
133
  // Normalize hostname
90
134
  if (urlObj.hostname === 'www.github.com') {
@@ -92,7 +136,19 @@ export function parseGitHubUrl(url) {
92
136
  urlObj = new globalThis.URL(normalizedUrl);
93
137
  }
94
138
  // Parse the pathname
95
- const pathParts = urlObj.pathname.split('/').filter(p => p);
139
+ let pathParts = urlObj.pathname.split('/').filter(p => p);
140
+ // Issue #2194: `/owner/repo/pulls/30` carries every byte needed to address pull
141
+ // request 30 — restore it rather than reporting the pull request list page.
142
+ if (recover) {
143
+ const pathRepair = repairGitHubPathParts(pathParts);
144
+ if (pathRepair.repairs.length > 0) {
145
+ repairs = repairs.concat(pathRepair.repairs);
146
+ pathParts = pathRepair.parts;
147
+ urlObj.pathname = `/${pathParts.join('/')}`;
148
+ normalizedUrl = urlObj.toString().replace(/\/+$/, '');
149
+ traceUrlRecovery('repaired-path', { original: url, repaired: normalizedUrl, repairs, hidden });
150
+ }
151
+ }
96
152
  // Handle different GitHub URL patterns
97
153
  const result = {
98
154
  valid: true,
@@ -108,6 +164,7 @@ export function parseGitHubUrl(url) {
108
164
  protocol: 'https',
109
165
  path: urlObj.pathname,
110
166
  };
167
+ withRecoveryDiagnostics(result, url, repairs, hidden);
111
168
  // No path - just github.com
112
169
  if (pathParts.length === 0) {
113
170
  result.type = 'home';