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
Open
fix(skills): say which credential a skill pack sent, and keep it out of the error#939brahyam wants to merge 1 commit into
brahyam wants to merge 1 commit into
Conversation
…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.
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
When a skill pack fails to clone, the error an operator reads is git's:
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:
the credential this pack names (repo-token) was sent as the Authorization header and the server refused ita connector token for this host was sent as the Authorization header and the server refused itno credential was sent: the one this pack names (repo-token) is missing, disabled, or delivered by environment rather than through the brokerno credential was sent: this deployment resolves none for skill packs, so a pack can only reach a public repositoryThe distinctions are not cosmetic.
wiring.tsomitsresolveAuthentirely when there is no keychain, and filters anenv-delivered credential toundefined— 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 }keptexecFile's original error, whose.stderrholds the injectedAuthorizationheader verbatim.util.inspectwalks causes by default, soconsole.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 againstinspect(err, { depth: 4 })rather thanerr.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
registerSkillPackcatches the fetch failure, writes it tolastImport.error, and returns the pack — soPOST /v1/admin/skill-packsanswers 200 and the admin renders it as success. Nothing in the admin readslastImport.error. So on the register path, which is where a mistypedauthCredentialSlugis most likely to be introduced, an operator sees "registered" and a row with no skills, and never reads this sentence; only/syncand/catalogsurface 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.tscovers 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 injectedGIT_CONFIG_VALUE_*back on stderr, and the test asserts the header appears asAuthorization: ***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 andprettier --checkpass.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.