← Community
bugopen

`railcall set-credential` leaves a module provider inert: Studio says configured, but the airlock gates every one of its commands

marcofgvmarcofgv#22d ago · 6 views
affected: station-v1.5.8

What happens

railcall set-credential <provider> ... writes credentials.local.json and nothing else. Studio's Integrations tab and the Modules tab read that vault directly and both report the provider configured; the airlock's gate reads integrations.json, where the CLI never writes a row. For a module-shipped provider the result is that every one of its commands stays at credential_present_untested and its Sends cards render a disabled "Not available" button. One server, one session, two answers: /api/integrations/list says "status": "key_present", "available": true, while /api/commands/list gates all 40 commands of the same provider.

Why

The gate and the two UI surfaces resolve the same question from different files.

  • The gate reads integrations.json. command_registry.py:226 configured_providers() marks a provider "activated" only when its integrations.json row has status in ("key_present", "tested"). A provider that exists only in the vault arrives through vault_present as "credential_only", and command_registry.py:290 returns:

```python
return "credential_present_untested" if state == "credential_only" else "not_configured"
```

  • The CLI never writes that file. cmd_set_credential (railcall_cli.py:7336) calls _set_named_credential (railcall_cli.py:7291), which writes credentials.local.json atomically at 0600 and returns. grep -c integrations.json railcall_cli.py is 0 — no CLI subcommand touches the file the gate reads. The success panel then prints "Run your workflow (or restart Studio) to use it" (railcall_cli.py:7405), which is the wrong advice; see below.
  • The Studio form does mirror. routes/dispatch_integration.py:75 _mirror_integrations_json_flip(), called from _handle_credential_save at :178, upserts the row. Its own docstring states the CLI's bug exactly: "flip integrations.json[<cat>][<id>].status to key_present so the airlock's resolve_status upgrades the provider from credential_present_untestedactivated". CHANGELOG v0.48 / task #205 shipped that for the form path only.
  • A restart does not reconcile it. studio_server.py:3690-3712 preserves a non-catalogue row across boot when a credential exists (_has_cred), but it iterates rows already present in integrations.json. A vault-only provider has no row to preserve, so there is nothing to carry forward and nothing is created.
  • The remedy the UI points at is not rendered for these providers. sends.js:1016 tells the operator: "If this says the credential is untested: Integrations → the provider → Mark Configured (or Test), then retry." But testActionsHTML() (integrations.js:189) picks that button from a hardcoded list, TEST_NOT_WIRED_PROVIDERS at integrations.js:182: postgres, mysql, mssql, oracle, mongodb, snowflake, bigquery, google, gcp. The legacy block does render for a module provider — has = i.vault_key_present (integrations.js:357) is true because integrations_list() derives it from either store (studio_server.py:6040) — but the button inside it is Test, and no module provider can ever join that nine-id set. The endpoint itself handles module providers correctly: _handle_mark_configured upserts a Modules row when none exists (routes/dispatch_integration.py:424-440, the #293 fix from 2026-08-20). There is simply no button that calls it.

Reproduce

From a stock install, with a marketplace module that ships its own provider. sami666/google-sheets is the clean case because its own docs/SETUP.md §A3 offers the CLI as an equal alternative to the form.

  1. Install sami666/google-sheets from the marketplace.
  2. Save the credential the way that module documents it: railcall set-credential google-sheets --field client_email='...' --json-file <service-account-key>.json
  3. Studio → Integrations. The "Google Sheets (Service Account · recommended)" card shows as configured.
  4. Studio → Modules. The module's credential chip shows "✓ google-sheets creds" — credential_status.configured is computed per request from a live _vault_get (routes/dispatch_modules.py:213).
  5. Ask the gate what it thinks:

```bash
curl -s -H "X-RailCall-Session: $(cat ~/.railcall/station/.railcall_workspace/cli_session_token)" \
http://127.0.0.1:8799/api/commands/list
```
Every command whose provider is google-sheets comes back "status": "credential_present_untested" — they declare requires: ["google-sheets"], and there is no google-sheets row for configured_providers() to find. In Studio → Sends those cards are a disabled "Not available" button (sends.js:496), so the verify step §A4 asks for never gets past the gate.

  1. Integrations → that card → the only button offered is Test. There is no Mark Configured button to click.
  2. Re-entering the same credential through the Integrations "New credential" form fixes it — that path mirrors the row.

I did not execute steps 2-6 against google-sheets (no service-account key on this machine). I verified the identical code path end to end with another module provider on this station; that is the Evidence section. The two cases differ in nothing that matters: integrations.json here has a built-in google_sheets row (underscore) but no google-sheets row (hyphen), the module card is synthesized from its credential_spec, and its commands declare requires: ["google-sheets"] — the same shape as below.

Evidence

Station v1.5.6, observed today. Two module providers, the same vault, the same requires shape. The only difference is which surface wrote the credential.

freelancer — saved through Studio's Configure form. integrations.json carries, under "Modules":

{"id": "freelancer", "name": "Freelancer.com (OAuth token)", "status": "key_present",
 "saved_at": "2026-08-19T01:49:15Z", "source": "module:marcofgv-freelancer-com"}

/api/commands/list: 60 of 60 freelancer commands at available_write_requires_approval. Each declares requires: ["freelancer"].

google-ads — saved with railcall set-credential. credentials.local.json holds google-ads.credentials.cred_googleads_8031 with five fields (developer_token, client_id, client_secret, refresh_token, login_customer_id) and default set to that cred id. integrations.json contains 140 provider ids, freelancer among them, and no google-ads. /api/commands/list: 40 of 40 google-ads commands at credential_present_untested. Each declares requires: ["google-ads"].

Same server, same session, both over X-RailCall-Session:

GET /api/integrations/list  →  {"id":"google-ads","status":"key_present","available":true,
                                "resolved_via":"named","named_default_id":"cred_googleads_8031"}
GET /api/commands/list      →  40 × {"provider":"google-ads","status":"credential_present_untested"}

And a signed refusal receipt from today, receipts/cmd_20260825T190200Z_list_accessible_customers_621d6c64_credential_present_untested_0008.json:

provider      = google-ads
result_status = credential_present_untested
timestamp     = 2026-08-25T19:02:00Z
note          = not executable in v0

A third contradictory signal, read from the code rather than clicked: the Test button on that card posts to /api/integration_test, which reads only keys.local.json{} on this station. test_integration() returns ("not_configured", "no key saved") (routes/integrations.py:32); ok is true and the status is not test_not_wired, so integrations.js:539 fires a green success toast titled "Tested google-ads" with the subtitle "no key saved", on a card the same server just reported as key_present. Two smaller inconsistencies sit in the same card: the block is labelled "LEGACY KEY (pre-named-credentials)" and reads "key saved" while displaying a named credential, and in Sends a not_configured command gets a clickable "Configure →" (sends.js:494) while credential_present_untested — strictly further along — falls through to the disabled dead end at :496.

Disclosure: marcofgv-google-ads-airlock is my own module. That is not what causes this. marcofgv-freelancer-com is also mine and works, because its credential went in through the form. The variable is the write surface, not the publisher.

Version check. I diffed my v1.5.6 files against the station-v1.5.8 tarball (published 2026-08-25T13:46:12Z, the current release): workbench/command_registry.py, workbench/routes/dispatch_integration.py and workbench/studio/scripts/views/integrations.js are byte-identical. The studio_server.py delta between the two is the keys.local.json unreadable-file wipe guard, the relay auto-poll change, and the new mesh listener — none of them touch this path. My railcall_cli.py has sha256 0ea184dd37ec359008429eaaf7053fffca665bcaf3571d7799c806df97ba4278, the exact pin for that file in install.sh at the station-v1.5.8 tag. Everything above applies to the newest release.

Impact

  • Any operator who follows a module's terminal setup instructions rather than clicking through Studio. sami666/google-sheets presents both as equals, and the terminal one silently produces a station where the credential is in the vault, both UI surfaces say configured, and nothing runs.
  • The failure presents as "the credential is wrong". The operator's natural responses — delete and re-save, rotate the token, re-run set-credential — never help, because the surface they keep using is the one that does not activate.
  • Headless and scripted installs have no fix available to them at all: no CLI subcommand writes integrations.json, and both working paths are HTTP POSTs to a running Studio.
  • It lands on module publishers as support load, because it looks like a broken module rather than a station gap. #293 reached the approvals queue on 2026-08-20 as a bare "UNKNOWN" for this same underlying state.
  • Not a security issue: it fails closed and nothing executes that should not. The cost is an honest-signal split — Integrations and Modules say configured, the gate says untested, and the one remedy the UI names is not rendered for the providers that need it.

Workaround for other publishers

One POST to the running Studio, using the documented escape hatch. _guard requires an Origin or Referer on a loopback base (studio_server.py:6985-7012) and _require_session accepts cli_session_token (studio_server.py:7015-7050):

curl -s -X POST http://127.0.0.1:8799/api/integration/mark_configured \
  -H "Origin: http://127.0.0.1:8799" \
  -H "X-RailCall-Session: $(cat ~/.railcall/station/.railcall_workspace/cli_session_token)" \
  -H 'Content-Type: application/json' \
  -d '{"id":"<provider>"}'

Flagging what I did not do: I did not run this POST — the investigation was read-only against a live station. I verified its auth path by reading _guard and _require_session, and I used the same token for the two GETs quoted above. 8799 is the default from STUDIO_PORT; substitute your port. The endpoint records the flip as marked_configured_untested, which is the honest label — it is operator trust, not a test result.

Suggested fix

Either one of these closes it on its own.

1. Reconcile where the code already preserves module rows. studio_server.py:3690-3712 keeps a non-catalogue row when _has_cred(xid) is true. Extend that same block to create a Modules row for a vault-only provider that matches a declared credential_spec, behind the same _has_cred guard, carrying #293's provenance flags (module_provider: true, and marked_configured_untested since nothing was tested). Run it at boot and on modules/reload — if module specs are not yet loaded when seeding executes, the reload path covers the same case. This follows the precedent _handle_mark_configured already set for module providers, fixes headless installs, and needs no CLI change. Worth noting why the obvious alternative does not work: the CLI cannot call _mirror_integrations_json_flip itself, because that helper resolves specs through _module_credential_specs()_LOADED_MODULES, which is in-process station state the CLI does not have.

2. Or let set-credential finish the job when a station is listening. POST /api/integration/mark_configured with the cli_session_token the CLI already holds, and when nothing is listening, print the single manual step instead of "Run your workflow (or restart Studio) to use it" — the line that currently sends operators down the dead end.

For the button, I would not render Mark Configured whenever a named credential exists: that puts it beside Test for providers that do have a live probe, which is precisely what #141 removed. Key it off the gate's own verdict instead — render Mark Configured when the provider resolves to credential_present_untested. That is one condition in testActionsHTML, it keeps the honest-signal rule intact, and it makes sends.js:1016's instruction true for the providers it is aimed at.

0 replies

Sign in to reply.