Tools
Vancine Openclaw Provider
Vancine community provider plugin for OpenClaw
Install
npm install #
README
# Vancine OpenClaw provider plugin
**Package**: `@vancine/openclaw-provider` · **Version**: 0.1.0 ·
**License**: MIT · **Repository**:
https://github.com/VancineAI/vancine-openclaw-provider ·
**Compatibility**: `openclaw@2026.9.4` (exact peer dependency)
A **third-party (community) OpenClaw provider plugin**, produced on the
Vancine side for the Vancine Chat Completions API at
`https://vancine.com/v1`. It is **not** an official, built-in, or
certified OpenClaw/ClawHub plugin — from OpenClaw's perspective it is an
installable community package built against the `openclaw@2026.9.4` SDK
contract.
The plugin ships **no hardcoded model list**. On every catalog refresh it:
1. Fetches the public `GET https://vancine.com/api/pi/catalog` (no auth,
with `ETag`/`Last-Modified` conditional revalidation).
2. Filters rows to chat candidates that pass every documented Vancine Pi
eligibility condition (`provider=vancine`, `schemaVersion=1`,
`enabled`, `available`, `kind=chat`, `api=openai-completions`,
`endpoint=chat.completions`, `text` input, positive finite
`contextWindow`/`maxTokens`, finite non-negative costs,
`supportsDeveloperRole=false`). Nothing is inferred from model names.
3. Calls `GET https://vancine.com/v1/models` with the active
`VANCINE_API_KEY` (Bearer) to confirm which candidate ids the key's
account actually sees.
4. Publishes **only the exact intersection**, per credential.
Unknown, retired, or other-account model ids are refused whenever the
plugin holds verified catalog state for the current credential — the
enforcement path and its one documented zero-credential edge (core
semantics shared by every provider: an operator-configured entry with no
credential at all is attempted unauthenticated and fails upstream 401,
with zero plugin traffic) are described in *Unknown-model refusal*
below. Catalog failures (auth rejection, network, schema, empty
intersection) publish **nothing** and report a typed outcome
(`auth-rejected` / `unavailable`) through the SDK's catalog outcome
channel — there is no static seed to fall back to.
## Security properties (implemented and tested)
- **Key use**: the API key is sent only as `Authorization: Bearer` to
`https://vancine.com/v1/models` (and, at inference time, by the
OpenClaw host itself to `/v1/chat/completions`). The public Pi catalog
request never carries `Authorization`.
- **No redirects**: every plugin fetch uses `redirect: "error"` and also
rejects surfaced 3xx statuses, so a redirect can never replay the
Bearer key to another origin. Verified against the real host
(a `307 → http://127.0.0.1:9/collect` attack never reached the target).
- **Bounded fetches**: 10 s timeout budget and a 4 MiB streamed-body cap,
owned by this plugin (we do not borrow the SDK's `liveModelDiscovery`
guards — that helper is not used).
- **Host allowlist**: only `vancine.com` over HTTPS in production;
loopback only with an explicit `VANCINE_TEST_ALLOW_LOOPBACK=1` opt-in
(used by the isolated verification harness).
- **Key hygiene**: keys are never logged, never included in errors, never
written to any plugin-owned file, and never echoed into the provider
config returned to the host. In-memory caches are keyed by an
HMAC-SHA256 fingerprint of the key under a per-process random secret.
- **Non-secret markers are never tokens (exact identification)**: the
host can resolve a credential to a *marker* (env-var name like
`VANCINE_API_KEY`, `oauth:…`, `secretref-…`) with the real value in
`discoveryApiKey`. The plugin prefers `discoveryApiKey`, and
`fetchLiveModelIds` refuses markers outright (`marker_not_key`).
Round-4 rule: identification is **exact** — the host's own
`isNonSecretApiKeyMarker` (imported from the typed public subpath
`openclaw/plugin-sdk/provider-auth`: fixed non-secret markers,
`oauth:`/`secretref` prefixes, AWS SDK env markers) plus the ONE
env-var name this plugin declares (`VANCINE_API_KEY`). The former
broad "any all-caps ≥8 chars" heuristic is gone: legitimate all-caps
keys with digits or underscores are real keys and are sent as Bearer
tokens normally. Verified on the real host:
with `VANCINE_API_KEY` set, discovery sends the real value and the
zero-credential state produces **zero** `/v1/models` calls.
- **Key persistence (empirical)**: after real-host runs, the raw test key
exists only inside the host's own credential store
(`<state>/state/openclaw.sqlite` — the same persistence boundary every
OpenClaw provider key uses). No `models.json` was created, and neither
`openclaw.json` nor any plugin-owned file contained the key. The
persistence scan covers the ENTIRE run directory including evidence
logs (which are key-redacted before writing) and the packed tarball;
since round 4 the allowlist is **exact-path**: only
`state/state/openclaw.sqlite` and its `-wal`/`-shm` siblings in the
SAME directory pass — a path merely containing `openclaw.sqlite`
(e.g. `evidence/openclaw.sqlite-leak.log`) FAILs the scan (negative
probes in `test/unit/hostVerifyGates.test.ts`).
## Credential-scoped model state
Verified model sets are stored per credential scope
(`profile:<authProfileId>` / `direct:<mode>`), mirroring the bundled
github-copilot provider's scoping pattern in 2026.9.4. Consequences
(all verified against the real host):
- Key A → Key B: each credential publishes only its own intersection; no
carry-over.
- Success → 401: the scope is cleared; the host list drops all vancine
models; the previous round is not retained.
- Delisting in the Pi catalog: the model disappears on the next refresh
and `resolveDynamicModel` refuses it.
- A `304` from the Pi catalog reuses the cached public body but still
re-fetches `/v1/models` for the current key and recomputes the
intersection — a `304` never freezes the published set. A first-fetch
`304` with no cache is a hard failure.
**Three distinct freshness mechanisms** (round-4 directive — do not
conflate them):
1. **live-cache TTL** (60 s, keyed by credential fingerprint): pure
network dedup for `/v1/models` reads of the SAME key; 401/403 evicts
immediately.
2. **scope TTL** (60 s, keyed by credential scope): how long
`prepareDynamicModel`/`resolveDynamicModel`/`normalizeResolvedModel`
may TRUST a verified set. An expired scope is pruned (its models stop
resolving immediately) and the next prepare re-runs the FULL pipeline
with a **forced** live re-fetch (live-cache bypass): the Pi catalog
may answer `304`, but `/v1/models` is re-verified with the current
key and the intersection is recomputed. Failed revalidation
(network/401/empty) CLEARS the scope — stale models never survive.
Deterministically tested with an injected clock (no sleeps) in
`test/integration/scopeFreshness.test.ts`.
3. **host catalog refresh**: the host re-invokes `catalog.run` /
`liveCatalog` on its own schedule (`models list --refresh`, each CLI
call a fresh process); those runs rebuild scopes independently of
the two TTLs above.
## Unknown-model refusal (multi-layer)
1. `resolveDynamicModel` materializes only ids verified for the active
credential scope (5 attribution rules: strict scope → strong-profile
fail-closed → zero-state defer → single-scope attribution →
all-scopes consensus); everything provably unknown throws (the throw
propagates through the real host as a clean per-request error —
verified).
2. `prepareDynamicModel` refreshes credential scope state via the SDK's
own `resolveApiKeyForProvider` before the sync retry, so fresh
processes verify instead of guessing. Freshness gates: (round 3) an
existing scope is trusted only while its credential **fingerprint**
matches the credential the host resolves now — a same-profile key
rotation (A→B) rebuilds the scope before the sync retry, so an
A-exclusive model can never be materialized (or sent upstream) under
B; (round 4) fingerprint match alone is not enough — the scope must
also be within its **TTL** (60 s). Expired scopes are dropped and
fully revalidated (forced `/v1/models` re-fetch with the current key,
Pi `304` notwithstanding; intersection recomputed), so a model
delisted during the window is refused afterwards. If the current
credential is unresolvable or revalidation fails, the scope is
cleared (fail closed).
3. `preferRuntimeResolvedModel: true` opts **operator-configured**
`models.providers.vancine.models[]` entries into the same comparison
chain. Without it, core resolves operator config before any plugin
hook (empirically confirmed bypass, run-006); with it, a
hand-configured unknown model is refused before any upstream request
(run-007/009 evidence: `chat=0`, plugin refusal message).
4. `normalizeResolvedModel` adjudicates **only with current-key
attribution**: it fingerprints the synchronously available key
material (process env value, config literal / env-marker) and enforces
strictly against scopes built from THAT key. An id verified by some
other scope is never treated as verified-for-the-current-key. When no
key material is synchronously resolvable (profile-store auth) or no
scope matches, it defers (null) rather than false-rejecting — the
async prepare/resolve chain enforces with proper attribution.
Documented residual behavior (zero-state, evidenced in run-008/009):
in
a process with **no credential at all**, an operator-configured model is
materialized by core and attempted **unauthenticated** (zero `/v1/models`
calls, zero credentialed chat calls; the mock/production upstream answers
401 and the turn fails). This is core semantics shared by every provider
(configured entry + missing credential); the plugin guarantees no marker
or stale credential is ever attached, and no service is obtained.
## Known limitations
- **Zero-credential + operator-configured explicit model**: OpenClaw core
issues ONE unauthenticated attempt that the upstream reje
... (truncated)
tools
Comments
Sign in to leave a comment