@oss-scout/core
Advanced tools
+19
-12
@@ -13,2 +13,3 @@ #!/usr/bin/env node | ||
| import { CONCRETE_STRATEGIES, SearchStrategySchema } from "./core/schemas.js"; | ||
| import { parseStrictInt } from "./commands/validation.js"; | ||
| function handleCommandError(err, options) { | ||
@@ -122,4 +123,4 @@ if (options.json) { | ||
| const { runSearch } = await import("./commands/search.js"); | ||
| const maxResults = count ? parseInt(count, 10) : 10; | ||
| if (isNaN(maxResults) || maxResults < 1) { | ||
| const maxResults = count ? parseStrictInt(count, "count") : 10; | ||
| if (maxResults < 1) { | ||
| throw new ValidationError("count must be a positive integer"); | ||
@@ -202,4 +203,4 @@ } | ||
| const { runFeatures } = await import("./commands/features.js"); | ||
| const maxResults = count ? parseInt(count, 10) : 10; | ||
| if (isNaN(maxResults) || maxResults < 1 || maxResults > 50) { | ||
| const maxResults = count ? parseStrictInt(count, "count") : 10; | ||
| if (maxResults < 1 || maxResults > 50) { | ||
| throw new ValidationError("count must be an integer between 1 and 50"); | ||
@@ -209,4 +210,4 @@ } | ||
| if (options.anchorThreshold !== undefined) { | ||
| const parsed = parseInt(options.anchorThreshold, 10); | ||
| if (isNaN(parsed) || parsed < 1 || parsed > 50) { | ||
| const parsed = parseStrictInt(options.anchorThreshold, "--anchor-threshold"); | ||
| if (parsed < 1 || parsed > 50) { | ||
| throw new ValidationError("--anchor-threshold must be an integer between 1 and 50"); | ||
@@ -218,3 +219,4 @@ } | ||
| if (options.splitRatio !== undefined) { | ||
| const parsed = Number.parseFloat(options.splitRatio); | ||
| // Number() rejects trailing garbage ("0.6abc"), unlike parseFloat. | ||
| const parsed = Number(options.splitRatio); | ||
| if (isNaN(parsed) || parsed < 0 || parsed > 1) { | ||
@@ -333,8 +335,13 @@ throw new ValidationError("--split-ratio must be a number between 0 and 1"); | ||
| .option("--prune", "Remove unavailable issues from saved results") | ||
| .option("--concurrency <n>", "Max concurrent API requests (default: 5)", parseInt) | ||
| .option("--concurrency <n>", "Max concurrent API requests (default: 5)") | ||
| .option("--json", "Output as JSON") | ||
| .action(async (options) => runAction(options, async () => { | ||
| if (options.concurrency !== undefined && | ||
| (isNaN(options.concurrency) || options.concurrency < 1)) { | ||
| throw new ValidationError("--concurrency must be a positive integer"); | ||
| let concurrency; | ||
| if (options.concurrency !== undefined) { | ||
| // Parsed in the action (not a commander argParser) so a bad value | ||
| // honors the --json error contract instead of a raw parse error. | ||
| concurrency = parseStrictInt(options.concurrency, "--concurrency"); | ||
| if (concurrency < 1) { | ||
| throw new ValidationError("--concurrency must be a positive integer"); | ||
| } | ||
| } | ||
@@ -346,3 +353,3 @@ const { runVetList } = await import("./commands/vet-list.js"); | ||
| prune: options.prune, | ||
| concurrency: options.concurrency, | ||
| concurrency, | ||
| }); | ||
@@ -349,0 +356,0 @@ if (options.json) { |
@@ -19,2 +19,8 @@ /** | ||
| export declare function runResults(options?: ResultsOptions): Promise<SavedCandidate[]>; | ||
| /** | ||
| * Clear all saved results via the scout so deletion tombstones are recorded | ||
| * (#117). Writing an empty list straight to the local file skipped the | ||
| * tombstones, and the next gist merge (union by URL) resurrected every | ||
| * "cleared" result from the remote copy (#276). Mirrors runSkipClear. | ||
| */ | ||
| export declare function runResultsClear(): Promise<void>; |
| /** | ||
| * Results command — display and manage saved search results. | ||
| */ | ||
| import { loadLocalState, saveLocalState } from "../core/local-state.js"; | ||
| import { loadLocalState } from "../core/local-state.js"; | ||
| import { ValidationError } from "../core/errors.js"; | ||
| import { withScout } from "./with-scout.js"; | ||
| /** | ||
@@ -36,6 +37,12 @@ * Return saved results, optionally narrowed to "new" ones. `--since` takes an | ||
| } | ||
| /** | ||
| * Clear all saved results via the scout so deletion tombstones are recorded | ||
| * (#117). Writing an empty list straight to the local file skipped the | ||
| * tombstones, and the next gist merge (union by URL) resurrected every | ||
| * "cleared" result from the remote copy (#276). Mirrors runSkipClear. | ||
| */ | ||
| export async function runResultsClear() { | ||
| const state = loadLocalState(); | ||
| state.savedResults = []; | ||
| saveLocalState(state); | ||
| await withScout(undefined, (scout) => { | ||
| scout.clearResults(); | ||
| }, { requireToken: false, persist: true }); | ||
| } |
@@ -8,4 +8,9 @@ /** | ||
| export async function runSync(options) { | ||
| // syncOpenPRs checkpoints itself, so withScout doesn't need to persist. | ||
| return withScout(options?.state, (scout) => scout.syncOpenPRs()); | ||
| // syncOpenPRs checkpoints itself, but in the CLI's non-gist mode the scout | ||
| // is built with `persistence: "provided"`, where checkpoint() is a no-op — | ||
| // only withScout's persist epilogue writes ~/.oss-scout/state.json. Without | ||
| // it, sync reported success while discarding every update (#275). | ||
| return withScout(options?.state, (scout) => scout.syncOpenPRs(), { | ||
| persist: true, | ||
| }); | ||
| } |
@@ -6,2 +6,8 @@ /** | ||
| export declare function validateGitHubUrl(url: string, pattern: RegExp, entityType: "issue"): void; | ||
| /** | ||
| * Parse a strictly-decimal integer CLI argument. Bare parseInt accepts | ||
| * trailing garbage ("50O" → 50), silently running with a different value | ||
| * than the user typed (#291). | ||
| */ | ||
| export declare function parseStrictInt(value: string, label: string): number; | ||
| export declare function validateUrl(url: string): string; |
@@ -12,2 +12,14 @@ /** | ||
| } | ||
| /** | ||
| * Parse a strictly-decimal integer CLI argument. Bare parseInt accepts | ||
| * trailing garbage ("50O" → 50), silently running with a different value | ||
| * than the user typed (#291). | ||
| */ | ||
| export function parseStrictInt(value, label) { | ||
| const trimmed = value.trim(); | ||
| if (!/^\d+$/.test(trimmed)) { | ||
| throw new ValidationError(`${label} must be an integer (got "${value}")`); | ||
| } | ||
| return parseInt(trimmed, 10); | ||
| } | ||
| export function validateUrl(url) { | ||
@@ -14,0 +26,0 @@ if (url.length > MAX_URL_LENGTH) { |
@@ -39,2 +39,9 @@ /** | ||
| export declare function getHttpStatusCode(error: unknown): number | undefined; | ||
| /** | ||
| * True for errors resolveErrorCode classifies as AUTH_REQUIRED: a 401, or a | ||
| * 403 that is not a rate-limit/abuse condition (fine-grained-token "Resource | ||
| * not accessible", SAML enforcement, ...). Retrying other work with the same | ||
| * token cannot succeed, so batch loops should abort on these (#290). | ||
| */ | ||
| export declare function isAuthError(error: unknown): boolean; | ||
| export declare function isRateLimitError(error: unknown): boolean; | ||
@@ -41,0 +48,0 @@ /** |
+12
-0
@@ -53,2 +53,14 @@ /** | ||
| } | ||
| /** | ||
| * True for errors resolveErrorCode classifies as AUTH_REQUIRED: a 401, or a | ||
| * 403 that is not a rate-limit/abuse condition (fine-grained-token "Resource | ||
| * not accessible", SAML enforcement, ...). Retrying other work with the same | ||
| * token cannot succeed, so batch loops should abort on these (#290). | ||
| */ | ||
| export function isAuthError(error) { | ||
| const status = getHttpStatusCode(error); | ||
| if (status === 401) | ||
| return true; | ||
| return status === 403 && !isRateLimitError(error); | ||
| } | ||
| export function isRateLimitError(error) { | ||
@@ -55,0 +67,0 @@ const status = getHttpStatusCode(error); |
@@ -79,2 +79,9 @@ /** | ||
| private bootstrapFromCache; | ||
| /** | ||
| * "missing" (no file/content) and "invalid" (content that fails parse or | ||
| * schema validation) are distinct outcomes: an invalid remote may be a | ||
| * corrupted-but-recoverable state or a newer state version written by a | ||
| * newer binary, so callers must not treat it as "nothing there" and | ||
| * overwrite it (#286). | ||
| */ | ||
| private fetchGistState; | ||
@@ -81,0 +88,0 @@ /** |
@@ -89,8 +89,23 @@ /** | ||
| // local snapshot, the prior best-effort behavior. | ||
| // | ||
| // Known limitation: the Gists API has no conditional update, so this | ||
| // read-merge-write has a small race window — two machines pushing | ||
| // near-simultaneously can drop additions made between the loser's fetch | ||
| // and write (tombstones protect only deletions). Union-merge keeps the | ||
| // window's blast radius to the most recent additions. | ||
| let toWrite = state; | ||
| try { | ||
| const remote = await this.fetchGistState(this.gistId); | ||
| if (remote) { | ||
| toWrite = mergeStates(state, remote); | ||
| if (remote.kind === "ok") { | ||
| toWrite = mergeStates(state, remote.state); | ||
| } | ||
| else if (remote.kind === "invalid") { | ||
| // The remote holds content this binary can't validate — possibly a | ||
| // newer state version or recoverable corruption. Overwriting would | ||
| // destroy it; mirror the bootstrap guard ("using local cache to | ||
| // avoid data loss") and report sync failure instead (#286). | ||
| this.writeCache(state); | ||
| warn(MODULE, "Remote gist content failed validation — not overwriting it. Changes saved locally; gist sync skipped."); | ||
| return false; | ||
| } | ||
| } | ||
@@ -138,5 +153,5 @@ catch (err) { | ||
| const fetched = await this.fetchGistState(cachedId); | ||
| if (fetched) { | ||
| if (fetched.kind === "ok") { | ||
| this.gistId = cachedId; | ||
| const state = this.mergeCacheInto(fetched); | ||
| const state = this.mergeCacheInto(fetched.state); | ||
| this.writeCache(state); | ||
@@ -165,4 +180,4 @@ return { gistId: cachedId, state, created: false }; | ||
| const fetched = await this.fetchGistState(search.id); | ||
| if (fetched) { | ||
| const state = this.mergeCacheInto(fetched); | ||
| if (fetched.kind === "ok") { | ||
| const state = this.mergeCacheInto(fetched.state); | ||
| this.writeCache(state); | ||
@@ -217,2 +232,9 @@ return { gistId: search.id, state, created: false }; | ||
| // ── Gist API operations ────────────────────────────────────────────── | ||
| /** | ||
| * "missing" (no file/content) and "invalid" (content that fails parse or | ||
| * schema validation) are distinct outcomes: an invalid remote may be a | ||
| * corrupted-but-recoverable state or a newer state version written by a | ||
| * newer binary, so callers must not treat it as "nothing there" and | ||
| * overwrite it (#286). | ||
| */ | ||
| async fetchGistState(gistId) { | ||
@@ -222,10 +244,10 @@ const { data } = await this.octokit.gists.get({ gist_id: gistId }); | ||
| if (!file?.content) | ||
| return null; | ||
| return { kind: "missing" }; | ||
| try { | ||
| const parsed = JSON.parse(file.content); | ||
| return parseScoutState(parsed); | ||
| return { kind: "ok", state: parseScoutState(parsed) }; | ||
| } | ||
| catch (err) { | ||
| warn(MODULE, `Gist content failed validation: ${errorMessage(err)}`); | ||
| return null; | ||
| return { kind: "invalid" }; | ||
| } | ||
@@ -343,4 +365,7 @@ } | ||
| return [...byUrl.values()].filter((t) => { | ||
| // An unparseable removedAt is dropped: keeping it (the old behavior) | ||
| // made it immortal — it never aged past the TTL and, compared lexically | ||
| // in applyTombstones, suppressed its URL on every merge forever (#292). | ||
| const ts = new Date(t.removedAt).getTime(); | ||
| return !Number.isFinite(ts) || ts >= cutoff; | ||
| return Number.isFinite(ts) && ts >= cutoff; | ||
| }); | ||
@@ -425,3 +450,5 @@ } | ||
| function mergeRepoScores(local, remote) { | ||
| const merged = { ...local }; | ||
| // Null prototype: repo names are parsed record keys, so a "__proto__" key | ||
| // must land as an own property, not a prototype assignment. | ||
| const merged = Object.assign(Object.create(null), local); | ||
| for (const [repo, remoteScore] of Object.entries(remote)) { | ||
@@ -428,0 +455,0 @@ const localScore = merged[repo]; |
@@ -77,3 +77,9 @@ /** | ||
| return false; | ||
| // An unparseable updated_at (the adapters map a missing timestamp to | ||
| // "") makes daysBetween return NaN, and NaN > maxAgeDays is false — so | ||
| // undated issues sailed through the staleness filter (#289). Treat an | ||
| // unknown age as failing it. | ||
| const updatedAt = new Date(item.updated_at); | ||
| if (Number.isNaN(updatedAt.getTime())) | ||
| return false; | ||
| const ageDays = daysBetween(updatedAt, config.now); | ||
@@ -366,3 +372,6 @@ if (ageDays > config.maxAgeDays) | ||
| const tracker = this.budgetTracker; | ||
| let searchBudget = LOW_BUDGET_THRESHOLD - 1; | ||
| // Fallback below the critical threshold so a failed preflight actually | ||
| // skips the starred phase — the old fallback of 19 passed the gate and | ||
| // made the "conservative budget" warning a lie (#288). | ||
| let searchBudget = CRITICAL_BUDGET_THRESHOLD - 1; | ||
| try { | ||
@@ -388,8 +397,13 @@ const rateLimit = await checkRateLimit(this.githubToken); | ||
| tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 60000).toISOString()); | ||
| warn(MODULE, "Could not check rate limit — using conservative budget, skipping heavy phases:", errorMessage(error)); | ||
| warn(MODULE, "Could not check rate limit — using conservative budget, skipping the starred phase:", errorMessage(error)); | ||
| } | ||
| if (searchBudget <= 0) { | ||
| // An exhausted REST Search bucket is no reason to abort the run: | ||
| // Phase 0/1 read the much larger Core API bucket and broad/maintained | ||
| // run on GraphQL (#284). The starred-phase gate below already skips the | ||
| // one budget-gated phase, and the tracker paces the remaining | ||
| // merge-stat search calls until the quota resets. | ||
| this.rateLimitWarning = | ||
| "GitHub search API quota exhausted. Try again after the rate limit resets."; | ||
| return { candidates: [], strategiesUsed: [] }; | ||
| "GitHub search API quota exhausted — skipping the starred phase; other phases don't use it."; | ||
| warn(MODULE, this.rateLimitWarning); | ||
| } | ||
@@ -396,0 +410,0 @@ // Derive search context |
@@ -248,2 +248,17 @@ /** | ||
| } | ||
| // commentCount comes from a prefetch that may be minutes old. If the | ||
| // computed last page came back full, comments may have crossed a page | ||
| // boundary since — exactly the busy issues where fresh claims land — so | ||
| // fetch one more page for the true tail (#293). | ||
| const lastFetched = recentComments.length % PER_PAGE; | ||
| if (recentComments.length > 0 && lastFetched === 0) { | ||
| const response = await octokit.issues.listComments({ | ||
| owner, | ||
| repo, | ||
| issue_number: issueNumber, | ||
| per_page: PER_PAGE, | ||
| page: lastPage + 1, | ||
| }); | ||
| recentComments.push(...response.data); | ||
| } | ||
| for (const comment of recentComments) { | ||
@@ -250,0 +265,0 @@ if (commentClaimsIssue(comment.body || "")) { |
@@ -10,3 +10,3 @@ /** | ||
| import { parseGitHubUrl } from "./utils.js"; | ||
| import { ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, } from "./errors.js"; | ||
| import { ValidationError, errorMessage, isAuthError, isRateLimitError, } from "./errors.js"; | ||
| import { debug, warn } from "./logger.js"; | ||
@@ -544,3 +544,6 @@ import { calculateRepoQualityBonus, calculateViabilityScore, } from "./issue-scoring.js"; | ||
| .catch((error) => { | ||
| if (getHttpStatusCode(error) === 401) { | ||
| // Abort on anything resolveErrorCode calls AUTH_REQUIRED — 401 or a | ||
| // non-rate-limit 403 (SAML, token scope). Retrying the rest of the | ||
| // batch with the same token can only burn budget (#290). | ||
| if (isAuthError(error)) { | ||
| firstAuthError ??= error; | ||
@@ -547,0 +550,0 @@ return; |
@@ -12,2 +12,3 @@ /** | ||
| import { ValidationError } from "./errors.js"; | ||
| import { assertValidTriageHost } from "./slm-triage.js"; | ||
| export const FIELD_CONFIGS = { | ||
@@ -138,2 +139,7 @@ githubUsername: { type: "string" }, | ||
| case "string": | ||
| // Issue text is POSTed to slmTriageHost during vetting, so it must | ||
| // stay a local/private address (#300). Validated here (not in the | ||
| // schema) so states persisted before this rule still parse. | ||
| if (key === "slmTriageHost") | ||
| assertValidTriageHost(value); | ||
| prefs[key] = value; | ||
@@ -140,0 +146,0 @@ break; |
@@ -10,3 +10,3 @@ /** | ||
| import { warn } from "./logger.js"; | ||
| import { getHttpCache, cachedRequest, cachedTimeBased } from "./http-cache.js"; | ||
| import { getHttpCache, cachedRequest, cachedTimeBased, versionedCacheKey, } from "./http-cache.js"; | ||
| import { probeRepoFile } from "./probe-repo-file.js"; | ||
@@ -94,3 +94,7 @@ import { getSearchBudgetTracker, } from "./search-budget.js"; | ||
| const cache = getHttpCache(); | ||
| const healthCacheKey = `health:${owner}/${repo}`; | ||
| // Versioned (#158): the cached ProjectHealth is read back with an unchecked | ||
| // cast, and its shape has changed before (#248 added recentMergedPRCount) — | ||
| // a stale-shaped entry degraded every approve to needs_review for the | ||
| // cache TTL (#285). | ||
| const healthCacheKey = versionedCacheKey(`health:${owner}/${repo}`); | ||
| try { | ||
@@ -97,0 +101,0 @@ return await cachedTimeBased(cache, healthCacheKey, HEALTH_CACHE_TTL_MS, async () => { |
@@ -26,2 +26,9 @@ /** | ||
| /** | ||
| * Slots handed out by waitForBudget() but not yet recorded. Without this, | ||
| * concurrent callers (vetting runs 3 tasks) could all pass waitForBudget() | ||
| * on an effective budget of 1 before any recordCall() landed, overshooting | ||
| * the external quota into real 429s (#287). | ||
| */ | ||
| private reservedCalls; | ||
| /** | ||
| * Initialize with pre-flight rate limit data from GitHub. | ||
@@ -31,3 +38,5 @@ */ | ||
| /** | ||
| * Record that a Search API call was just made. | ||
| * Record that a Search API call was just made. Releases the reservation | ||
| * taken by the preceding waitForBudget() (every call site pairs them, with | ||
| * recordCall in a finally). | ||
| */ | ||
@@ -60,3 +69,5 @@ recordCall(): void; | ||
| /** | ||
| * Wait if necessary to stay within the Search API rate limit. | ||
| * Wait if necessary to stay within the Search API rate limit, then reserve | ||
| * one call slot. The reservation is released by the paired recordCall(), so | ||
| * concurrent callers cannot all claim the same last remaining slot (#287). | ||
| * If the sliding window is at capacity, sleeps until the oldest | ||
@@ -63,0 +74,0 @@ * call ages out of the window. |
@@ -36,2 +36,9 @@ /** | ||
| /** | ||
| * Slots handed out by waitForBudget() but not yet recorded. Without this, | ||
| * concurrent callers (vetting runs 3 tasks) could all pass waitForBudget() | ||
| * on an effective budget of 1 before any recordCall() landed, overshooting | ||
| * the external quota into real 429s (#287). | ||
| */ | ||
| reservedCalls = 0; | ||
| /** | ||
| * Initialize with pre-flight rate limit data from GitHub. | ||
@@ -45,8 +52,13 @@ */ | ||
| this.callsSinceReset = 0; | ||
| this.reservedCalls = 0; | ||
| debug(MODULE, `Initialized: ${remaining} remaining, resets at ${new Date(this.resetAt).toLocaleTimeString()}`); | ||
| } | ||
| /** | ||
| * Record that a Search API call was just made. | ||
| * Record that a Search API call was just made. Releases the reservation | ||
| * taken by the preceding waitForBudget() (every call site pairs them, with | ||
| * recordCall in a finally). | ||
| */ | ||
| recordCall() { | ||
| if (this.reservedCalls > 0) | ||
| this.reservedCalls--; | ||
| this.callTimestamps.push(Date.now()); | ||
@@ -99,5 +111,6 @@ this.totalCalls++; | ||
| // Use the stricter of: local window limit vs. known remaining quota | ||
| // minus calls made since the last reset | ||
| const localBudget = EFFECTIVE_BUDGET - this.callTimestamps.length; | ||
| const externalBudget = this.knownRemaining - this.callsSinceReset; | ||
| // minus calls made since the last reset. Outstanding reservations count | ||
| // against both so concurrent waiters can't share one remaining slot. | ||
| const localBudget = EFFECTIVE_BUDGET - this.callTimestamps.length - this.reservedCalls; | ||
| const externalBudget = this.knownRemaining - this.callsSinceReset - this.reservedCalls; | ||
| return Math.max(0, Math.min(localBudget, externalBudget)); | ||
@@ -113,3 +126,5 @@ } | ||
| /** | ||
| * Wait if necessary to stay within the Search API rate limit. | ||
| * Wait if necessary to stay within the Search API rate limit, then reserve | ||
| * one call slot. The reservation is released by the paired recordCall(), so | ||
| * concurrent callers cannot all claim the same last remaining slot (#287). | ||
| * If the sliding window is at capacity, sleeps until the oldest | ||
@@ -124,2 +139,3 @@ * call ages out of the window. | ||
| if (this.getEffectiveBudget() > 0) { | ||
| this.reservedCalls++; | ||
| return; // Budget available, no wait needed | ||
@@ -130,2 +146,8 @@ } | ||
| if (!oldestInWindow) { | ||
| if (this.reservedCalls > 0) { | ||
| // Budget is consumed only by outstanding reservations; their calls | ||
| // will land shortly. Poll rather than waiting for the quota reset. | ||
| await sleep(250); | ||
| continue; | ||
| } | ||
| // No calls in window — the external quota is exhausted. Wait for | ||
@@ -139,2 +161,3 @@ // GitHub's reset when we know it, otherwise proceed (#119). | ||
| } | ||
| this.reservedCalls++; | ||
| return; | ||
@@ -141,0 +164,0 @@ } |
@@ -58,2 +58,14 @@ /** | ||
| /** | ||
| * Guard for user-configured Ollama hosts (#300): http(s) scheme and a | ||
| * local/private address only. Issue titles/bodies are POSTed to this host | ||
| * during vetting, so an arbitrary public URL would let a config change | ||
| * beacon fetched content off-machine. | ||
| */ | ||
| export declare function isAllowedTriageHost(raw: string): boolean; | ||
| /** | ||
| * Throw a ValidationError unless the value is empty (use the default) or an | ||
| * allowed local/private Ollama host. Called by config-set (CLI + MCP). | ||
| */ | ||
| export declare function assertValidTriageHost(value: string): void; | ||
| /** | ||
| * Run an SLM triage classification. Returns `null` on any failure path | ||
@@ -60,0 +72,0 @@ * — caller treats `null` as "no SLM signal available". |
@@ -0,1 +1,4 @@ | ||
| import { ValidationError } from "./errors.js"; | ||
| import { warn } from "./logger.js"; | ||
| const MODULE = "slm-triage"; | ||
| /** Default Ollama HTTP endpoint when not overridden. */ | ||
@@ -43,2 +46,49 @@ const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"; | ||
| /** | ||
| * Guard for user-configured Ollama hosts (#300): http(s) scheme and a | ||
| * local/private address only. Issue titles/bodies are POSTed to this host | ||
| * during vetting, so an arbitrary public URL would let a config change | ||
| * beacon fetched content off-machine. | ||
| */ | ||
| export function isAllowedTriageHost(raw) { | ||
| let url; | ||
| try { | ||
| url = new URL(raw); | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| if (url.protocol !== "http:" && url.protocol !== "https:") | ||
| return false; | ||
| const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase(); | ||
| if (host === "localhost" || host === "::1") | ||
| return true; | ||
| const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); | ||
| if (v4) { | ||
| const a = Number(v4[1]); | ||
| const b = Number(v4[2]); | ||
| if (a === 127 || a === 10) | ||
| return true; | ||
| if (a === 192 && b === 168) | ||
| return true; | ||
| if (a === 172 && b >= 16 && b <= 31) | ||
| return true; | ||
| return false; | ||
| } | ||
| if (host.includes(":")) { | ||
| // IPv6 literals: unique-local (fc00::/7) and link-local (fe80::/10). | ||
| return (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")); | ||
| } | ||
| // Single-label LAN hostnames ("ollama-box") and mDNS .local names. | ||
| return !host.includes(".") || host.endsWith(".local"); | ||
| } | ||
| /** | ||
| * Throw a ValidationError unless the value is empty (use the default) or an | ||
| * allowed local/private Ollama host. Called by config-set (CLI + MCP). | ||
| */ | ||
| export function assertValidTriageHost(value) { | ||
| if (value === "" || isAllowedTriageHost(value)) | ||
| return; | ||
| throw new ValidationError(`slmTriageHost must be an http(s) URL pointing at localhost or a private/LAN address (got "${value}"). Example: http://192.168.1.20:11434`); | ||
| } | ||
| /** | ||
| * Run an SLM triage classification. Returns `null` on any failure path | ||
@@ -51,2 +101,9 @@ * — caller treats `null` as "no SLM signal available". | ||
| const host = options.host ?? DEFAULT_OLLAMA_HOST; | ||
| // Enforced at use time too, covering hosts written straight into | ||
| // state.json without going through config-set (#300). Fail open like | ||
| // every other triage failure path. | ||
| if (!isAllowedTriageHost(host)) { | ||
| warn(MODULE, `slmTriageHost "${host}" is not a local/private address; skipping SLM triage`); | ||
| return null; | ||
| } | ||
| const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
@@ -53,0 +110,0 @@ const fetchFn = options.fetchImpl ?? fetch; |
+5
-0
@@ -856,2 +856,7 @@ /** | ||
| try { | ||
| // Reload-and-merge before writing: a long-lived scout (the MCP | ||
| // server runs for days) would otherwise last-writer-wins clobber | ||
| // state.json changes made by the CLI since boot (#294). The | ||
| // tombstone-aware merge preserves both sides, mirroring gist mode. | ||
| this.state = mergeStates(this.state, loadLocalState()); | ||
| saveLocalState(this.state); | ||
@@ -858,0 +863,0 @@ } |
+6
-7
| { | ||
| "name": "@oss-scout/core", | ||
| "version": "1.5.0", | ||
| "version": "1.5.1", | ||
| "description": "Personalized GitHub issue finder with multi-strategy search, deep vetting, and viability scoring — CLI, library, MCP server, and Claude Code plugin", | ||
@@ -11,8 +11,8 @@ "type": "module", | ||
| ".": { | ||
| "import": "./dist/index.js", | ||
| "types": "./dist/index.d.ts" | ||
| "types": "./dist/index.d.ts", | ||
| "import": "./dist/index.js" | ||
| }, | ||
| "./types": { | ||
| "import": "./dist/core/types.js", | ||
| "types": "./dist/core/types.d.ts" | ||
| "types": "./dist/core/types.d.ts", | ||
| "import": "./dist/core/types.js" | ||
| } | ||
@@ -23,3 +23,2 @@ }, | ||
| "!dist/**/*.map", | ||
| "!dist/core/test-utils.*", | ||
| "!dist/eval/**" | ||
@@ -71,3 +70,3 @@ ], | ||
| "build": "tsc", | ||
| "bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --sourcemap --outfile=dist/cli.bundle.cjs", | ||
| "bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --outfile=dist/cli.bundle.cjs", | ||
| "start": "tsx src/cli.ts", | ||
@@ -74,0 +73,0 @@ "typecheck": "tsc --noEmit", |
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1127157
1.3%14517
1.77%