Skip to content

fix(skills): say which credential a skill pack sent, and keep it out of the error - #939

Open
brahyam wants to merge 1 commit into
yc-software:mainfrom
brahyam:skill-pack-auth-failure-message
Open

fix(skills): say which credential a skill pack sent, and keep it out of the error#939
brahyam wants to merge 1 commit into
yc-software:mainfrom
brahyam:skill-pack-auth-failure-message

Conversation

@brahyam

@brahyam brahyam commented Sep 4, 2026

Copy link
Copy Markdown

What

When a skill pack fails to clone, the error an operator reads is git's:

fatal: could not read Username for 'https://…': terminal prompts disabled

That sentence describes a missing credential, and the fetcher raises it verbatim. It is what git says whenever the server answers 401 and there is no terminal to prompt at — equally true when a credential was sent and refused. The reader cannot tell the two apart, and the message actively points at the wrong one.

The fetcher knows which case it is in, so it now says. Four situations, each with a different remedy:

situation appended
a pack-named credential was sent and refused the credential this pack names (repo-token) was sent as the Authorization header and the server refused it
a connector token was sent and refused a connector token for this host was sent as the Authorization header and the server refused it
the pack names one that did not resolve no credential was sent: the one this pack names (repo-token) is missing, disabled, or delivered by environment rather than through the broker
the deployment resolves none at all no credential was sent: this deployment resolves none for skill packs, so a pack can only reach a public repository

The distinctions are not cosmetic. wiring.ts omits resolveAuth entirely when there is no keychain, and filters an env-delivered credential to undefined — so a blanket "this pack has no credential" blames the pack for two things that are not its fault and sends an operator to set a field they already set. Naming the connector-token path separately matters for the same reason: its remedy is reconnecting the account, not editing the keychain.

The 404

GitHub answers a valid but unauthorized token with remote: Repository not found. rather than 403, deliberately, so as not to leak repository existence. That is the most common real authorization failure on the one host this file special-cases, and it does not look like an auth error at all. A not-found now carries the ambiguity it actually has — that a repository which does not exist and one the credential cannot see are answered the same way — followed by which credential was sent.

The credential was still in the error object

The thrown message was masked while { cause: e } kept execFile's original error, whose .stderr holds the injected Authorization header verbatim. util.inspect walks causes by default, so console.error(err) printed the credential in full. Only a masked copy of the cause is attached now.

This is why the new scrub() coverage asserts against inspect(err, { depth: 4 }) rather than err.message — the first version of that test passed while the object being thrown still carried the secret, which is worse than having no test at all.

Smaller things in the same path

execFile's message ends in a newline, so the appended clause landed on its own line after a stray space; the message is trimmed first. A failure that is about neither credentials nor a missing repository passes through byte-for-byte, with no dangling separator.

Where this message still does not reach an operator

registerSkillPack catches the fetch failure, writes it to lastImport.error, and returns the pack — so POST /v1/admin/skill-packs answers 200 and the admin renders it as success. Nothing in the admin reads lastImport.error. So on the register path, which is where a mistyped authCredentialSlug is most likely to be introduced, an operator sees "registered" and a row with no skills, and never reads this sentence; only /sync and /catalog surface it.

That is a real gap and it is not this file's to close — it is either a UI change or a change to what the register route returns. Worth a follow-up.

Verified

test/pack-fetcher.test.ts covers all four situations, the 404, and the untouched pass-through.

It also gains the first test of scrub() in the repository. The stub git echoes its injected GIT_CONFIG_VALUE_* back on stderr, and the test asserts the header appears as Authorization: *** and that neither the secret nor its base64 form survives. Without the stub actually emitting the credential, every "the secret does not appear" assertion passes on a stub that never had it to leak — which is what the first version of this test did.

npm run typecheck, lint and prettier --check pass.


Related: #938 changes a different function in the same file (src/skills/pack-fetcher.ts) and adds cases to the same test file. Each stands alone; whichever lands second needs a rebase. #941 is the companion referred to above: it validates the slug this message names.

…of the error

A pack that fails to clone reports git's own sentence:

  fatal: could not read Username for 'https://…': terminal prompts disabled

which describes a MISSING credential. It is what git says whenever the server
answers 401 with no terminal to prompt at, and that is equally true when a
credential was sent and refused — so the message points at the wrong one of
the two and the reader cannot tell them apart.

Four situations are now named, because each has a different remedy: a
pack-named credential refused, a connector token refused, a pack whose named
credential did not resolve, and a deployment that resolves none at all. The
last two are not pedantry — wiring.ts omits resolveAuth entirely when there
is no keychain, and filters an env-delivered credential to undefined, so a
blanket "this pack has no credential" blames the pack for two things that are
not its fault and sends an operator to set a field they already set. The
connector path is named separately because its remedy is reconnecting the
account rather than editing the keychain.

A not-found carries the ambiguity it has. GitHub answers a valid but
unauthorized token with `Repository not found` rather than 403, deliberately,
so as not to leak repository existence — that is the most common real
authorization failure on the one host this file special-cases, and it does
not look like an auth error at all.

The diagnosis leads rather than trailing git's `Command failed: …` preamble,
because the admin puts the whole string into a one-line status span and the
actionable half was the part that got clipped. execFile's message ends in a
newline, so it is trimmed first, and a failure about neither credentials nor
a missing repository passes through with no dangling separator.

THE CAUSE IS SCRUBBED, which is the reason this touches security and not only
prose. The thrown message was masked while `{ cause: e }` kept execFile's
original error, whose .stderr holds the injected Authorization header
verbatim — and util.inspect walks causes by default, so console.error(err)
printed the credential. Only a masked copy is attached now.

The tests gain the first coverage of scrub() in the repository. The stub git
echoes its injected GIT_CONFIG_VALUE_* back on stderr, and the assertions run
against inspect(err) rather than err.message alone, so the leak that survived
the mask cannot come back. The auth fixture is built through resolvePackAuth
rather than hand-written, so it cannot drift from what production produces.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant