Skip to content

A self-managed host connects, and says which half is wrong when it does not - #116

Merged
unitypark merged 1 commit into
mainfrom
feat/local-mode-reaches-a-self-managed-host
Aug 17, 2026
Merged

A self-managed host connects, and says which half is wrong when it does not#116
unitypark merged 1 commit into
mainfrom
feat/local-mode-reaches-a-self-managed-host

Conversation

@unitypark

@unitypark unitypark commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Found by connecting a project on a self-managed, corporate GitLab: a host that
is not gitlab.com, and a project two groups deep. Two problems, one root: specd could not reach a self-managed host, and would not
tell you why.

Grounded in knowledge/decisions/0020-local-mode-borrows-the-host-cli.md
(amended here) and knowledge/glossary.md.


1 · "Internal server error" on connect

GitLabAdapter.api() called fetch unguarded. fetch rejects with a plain
TypeError in exactly the two situations a self-managed instance produces —
and a TypeError is not an HttpException, so Nest rendered both as a 500:

gitlab.example.com        → TypeError: Failed to parse URL   (a bare host is not a URL —
                                                              WHATWG reads it as a scheme)
https://gitlab.internal   → TypeError: fetch failed          (cause.code = ENOTFOUND /
                                                              ECONNREFUSED / cert errors)

It sailed straight past the controller's instanceof VcsError catch — the
catch whose own docstring says it exists so that "your token is wrong" never
reads as "Internal server error". gitlab.com always resolves, so the gap
only ever opened on self-managed.

  • normalizeInstanceUrl reads a bare host as https, strips a trailing
    slash, and refuses what it cannot use — applied where the URL is stored, so
    the complaint lands on the field you just typed into rather than on the
    repository listing one call later.
  • describeTransportFailure turns cause.code into the sentence for that
    cause: VPN/DNS, connection refused, timeout, or an untrusted internal CA
    pointed at NODE_EXTRA_CA_CERTS rather than at disabling verification.
  • The same guard goes on GitHubAdapter.api(), which has the identical
    unguarded fetch and hides it behind api.github.com always being up. Fixing
    one instance of a bug and leaving its twin is not fixing the bug.

The instance URL keeps its path, on purpose

My first attempt reduced the URL to its origin, so a pasted project URL would
"just work". That was wrong and is reverted: GitLab supports being served from
a relative URL root (external_url 'https://host/gitlab'), where the API
really is at {origin}/gitlab/api/v4. Stripping the path would trade a
deployment somebody chose for a typo somebody made.

A pasted project URL instead 404s at the API base, and describeApiBase404
names which half to drop — while telling you to keep it if GitLab genuinely
lives on that subpath. Both readings are legitimate; only the operator knows
which is theirs.

2 · Local mode had no way to open a merge request on such a host

Two independent blockers: detectHost recognised only github.com and
gitlab.com, and there was no field anywhere to supply a token in local mode.

Decision 0020 refused to guess a self-managed host's software from its URL,
because guessing wrong means running gh against someone's private git server.
That still holds — but it forbids guessing the host, not being told it. A
token both names the software and authorises the call, so the restriction lifts
exactly when a credential is present.

The local step now takes an optional review credential:

Open pull/merge requests on   [ GitLab (gitlab.com or self-managed) ▾ ]
Instance URL                  https://gitlab.example.com
Access token                  glpat-…                    [ Check token ]
  • No migration. It lives on the vcs connection the project already owns —
    that row's encrypted_secret and settings.instanceUrl were both unused for
    provider: 'local'. settings.reviewProvider is the switch.
  • Proved at connect time via verify() on either adapter (mirroring
    JiraAdapter.verify()), reporting the account it belongs to — so a bad token
    fails in the wizard, not as a merge request that never appears.
  • Opt-in and absent by default. Without it, local mode behaves exactly as
    0020 specified, down to the wording of the hint.

The boundary

The token opens reviews and nothing else — it never reads a file, lists a
tree, clones, or pushes. Your own git credentials still do the push; specd
still reads the repository from disk. That narrowness is what makes it
tolerable in the mode whose promise is that specd holds nothing, and 0020's
amendment states the cost plainly rather than glossing it:

local mode's promise narrows from "specd holds no credential for your host"
to "unless you give it one, and then only to open reviews".


Verified against the shape that broke

A test pushes a branch to a real local bare repo, then opens the MR against a
stubbed API, asserting the request:

POST https://gitlab.example.com/api/v4/projects/acme%2Fservices%2Faurora-api/merge_requests
     { source_branch: "spec/E-101-add-csv-export", target_branch: "main",
       title: "[E-101] - Add CSV export" }

The nested group is percent-encoded whole — splitting it would address
acme/services, which is a group and not a project. The project path is
derived from origin, so it is never typed twice, and a subpath-hosted
instance has its prefix removed. The same shapes are covered for GitLab mode,
including that verify() hits the configured instance rather than gitlab.com.

pnpm typecheck, pnpm test (473 API tests), pnpm build and pnpm site:check
all pass. 15 new tests.

For the reviewer

  • No live self-managed instance was available, so this is verified against
    stubbed transports and reproduced failure modes, not a real corporate GitLab.
    The failure paths are all soft — worst case is the previous branch-only
    behaviour with a note — but a live check is worth doing.
  • glab's flags remain unverified from the earlier PR (Local mode opens a real PR, setup keeps your AGENTS.md, builds name their branch #115). Unchanged
    here, and now less load-bearing: a configured credential bypasses the CLI
    entirely, which is the path a corporate machine will actually take.

@unitypark unitypark self-assigned this Aug 17, 2026
…es not

Two things, found by connecting a project on a corporate GitLab.

Connecting one answered "Internal server error". `GitLabAdapter.api()` called
fetch unguarded, and fetch rejects with a plain TypeError in exactly the two
situations a self-managed instance produces: a bare host is not a URL (WHATWG
reads it as a scheme), and an unreachable host fails at the transport with the
reason on `cause.code`. A TypeError is not an HttpException, so it sailed past
the controller's `instanceof VcsError` catch — the catch whose own docstring
says it exists to stop "your token is wrong" reading as "Internal server
error". gitlab.com always resolves, so the gap only ever opened on
self-managed. `normalizeInstanceUrl` now reads a bare host as https and
refuses what it cannot use, at the point the URL is stored so the complaint
lands on the field somebody typed it into; `describeTransportFailure` turns
cause.code into the sentence for that cause — VPN, DNS, refused, timeout, an
untrusted internal CA pointed at NODE_EXTRA_CA_CERTS. The same guard goes on
the GitHub adapter, which has the identical unguarded fetch and hides it
behind api.github.com always being up.

The instance URL keeps its path, deliberately. Stripping to the origin would
be a kindness to someone pasting a project URL bought by breaking every GitLab
served from a relative URL root, and only one of those is a deployment
somebody chose. A pasted project URL instead 404s at the API base, and
`describeApiBase404` says which half to drop.

Local mode could not open a merge request on such a host at all: `detectHost`
refuses to guess what software a self-managed host runs (0020, property 2),
and `glab` is not on most corporate machines. But that reasoning forbids
*guessing* the host, not being *told* it. So a local-mode project may now carry
an optional review credential — provider, instance URL and token, on the `vcs`
connection it already owns, which needed no migration because that row's
encrypted_secret and settings.instanceUrl were both unused for provider
'local'. The token opens the review and nothing else: it never reads a file,
lists a tree, clones or pushes, and git still pushes with the machine's own
credentials. Absent, which is the default, local mode behaves exactly as
before, down to the wording of the hint. It is proved at connect time with
verify() on either adapter, the way JiraAdapter.verify() already worked, so a
wrong token fails in the wizard rather than as a merge request that never
appears.

0020 carries the amendment, including the honest version of the cost: local
mode's promise narrows from "specd holds no credential for your host" to
"unless you give it one, and then only to open reviews".
@unitypark
unitypark force-pushed the feat/local-mode-reaches-a-self-managed-host branch from bcb14b1 to e957c43 Compare August 17, 2026 16:29
@unitypark
unitypark merged commit ba31351 into main Aug 17, 2026
2 checks passed
@unitypark
unitypark deleted the feat/local-mode-reaches-a-self-managed-host branch August 17, 2026 16:34
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