m1-copilot-client
UC-1.13.1 — client-side Copilot: a VSCode extension + Node companion that
lets a Customer Operator chat with M1's existing agents, approve each
proposed command inline, and execute it on their own workstation under
their own identity. The legacy m1-execution-runner stays the
headless-automation lane (FR-8).
Status
Scaffold. Several slices are stubbed — see
M1-project-context/Plans/UC-1.13.1-Plan.md for the full implementation
plan and the open W-gates (W1 canonical-JSON sub-spec, W2 audit sink,
W4 latency instrumentation, W5 routing discriminator).
Architecture
extension host ←postMessage→ webview (React, message list + approval cards)
│
│ spawn (only here)
▼
companion executor ──HTTPS──▶ m1-agents /webhook/callbacks/{execution_id}
▲
│ outbound SSE
└──────────────────── m1-agents /events/stream/session/{session_id}
The companion is the only component that may invoke
child_process.spawn. The webview never executes anything. The extension
host never proxies command text without going through the companion.
Security model
Before execution, the companion verifies that the ProposedAction it
received was issued by the server:
- HMAC-SHA256 over canonical JSON of
(action_id, tool_name, args)
matches signed_digest. Keys are looked up by digest_kid from
extension settings, supporting dual-key rotation (NFR-6, BR-6).
expires_at is in the future. Expired actions are rejected.
- The corresponding
ApprovalDecision was observed on the SSE
stream. Without ACTION_APPROVED for the same action_id, the
extension host never invokes the executor (FR-5 / AC-5.1).
If any of these checks fail, the companion posts
state=rejected_no_approval and refuses to execute.
Client-side redaction (src/companion/redact.ts) runs over both stdout
and stderr before egress (NFR-13). The redaction list is best-effort and
is backstopped by server-side audit-sink scrubbing.
The bearer token comes from VSCode settings; the service URLs are derived
from the selected environment. TLS is required (NFR-4) — the derived URLs
are always https://.
Settings
Pick a single environment and the three service URLs (m1-agents,
Control Tower, and Keycloak) are mapped automatically — there is no longer
a per-URL setting to keep in sync.
| Setting |
Purpose |
m1Copilot.environment |
Target environment: PRD (default), UAT, or DEV. The m1-agents, Control Tower, and Keycloak URLs are derived from this. UAT/DEV only take effect when m1Copilot.devMode is enabled. |
m1Copilot.devMode |
Developer mode. When enabled, unlocks selecting the UAT/DEV environments (with real Keycloak auth). When disabled, the extension always targets PRD. |
m1Copilot.sessionId |
Optional; auto-generated if absent. |
m1Copilot.hmacKeys |
Deprecated / offline-dev only. When non-empty, overrides the bootstrap fetch with a static kid → hex key map. In production the keyset is fetched from m1-agents (see "HMAC key distribution" below). |
The Keycloak clientId is hardcoded (m1-copilot-client). The OAuth
scopes and an offline useMockAuth flag are no longer surfaced in the
settings UI; advanced users can still override m1Copilot.keycloak.scopes
and m1Copilot.useMockAuth directly in settings.json.
Keycloak ↔ m1-agents token bridge
The extension authenticates the operator with Keycloak (RS256 realm-signed
access tokens). m1-agents validates internal HS256 JWTs. The two are
bridged by POST /auth/exchange on m1-agents:
- After Keycloak login, the extension wraps the
KeycloakTokenProvider
in ExchangedTokenProvider.
- On the first downstream call, the extension POSTs the Keycloak access
token to
/auth/exchange with grant_type=urn:ietf:params:oauth:grant-type:token-exchange.
m1-agents validates the token against the realm's JWKS, reads
tenant_id from the configured claim (default name: tenant_id),
and returns a fresh access_token (5 min) + refresh_token (8 h).
- When the access token nears expiry, the extension calls
/auth/exchange
with grant_type=refresh_token — no Keycloak round-trip. Operators
can work 24/7 as long as refreshes succeed; if the refresh JWT also
expires, the extension transparently does a full re-exchange.
Every other m1-agents route (/chat, /events/stream/..., /config/companion-bootstrap,
/webhook/callbacks/...) receives the exchanged internal token, not
the raw Keycloak token.
HMAC key distribution
The companion verifies every ProposedAction.signed_digest against an
HMAC-SHA256 key shared with m1-agents. The key is not bundled in the
marketplace artifact (a leaked key lets anyone forge ProposedActions).
Instead:
- User installs the extension from the marketplace — no manual key
configuration needed.
- User runs Start M1 Copilot → Keycloak login.
- Extension calls
GET /config/companion-bootstrap on m1-agents with
the access token and receives {active_kid, keys, ttl_seconds}.
- The response is cached in
ExtensionContext.secrets (OS keychain) and
reused for that TTL. On HMAC verification failure (server-side
rotation), the cache is invalidated and re-fetched once.
For offline development without a live m1-agents, set m1Copilot.hmacKeys
to bypass the bootstrap fetch (the first kid becomes active), and set the
hidden m1Copilot.useMockAuth to true to use the mock token provider.
Keycloak setup
The M1 platform already runs two Keycloak realms managed by
m1-platform-gitops (apps/keycloak/envs/{uat,prd}):
| Env |
Realm URL |
Realm name |
| DEV |
https://auth-dev.m1ops.com/realms/m1-realm |
m1-realm |
| UAT |
https://keycloak-uat.m1ops.com/realms/m1-uat |
m1-uat |
| PRD |
https://keycloak.m1ops.com/realms/m1-prd |
m1-prd |
A dedicated public client m1-copilot-client is registered in both
realms with PKCE S256 required (no client secret). Redirect URIs cover
the native VSCode schemes:
vscode://Meridai.m1-copilot-client/*
vscode-insiders://Meridai.m1-copilot-client/*
Settings examples
DEV (requires developer mode):
{
"m1Copilot.devMode": true,
"m1Copilot.environment": "DEV"
}
UAT (requires developer mode):
{
"m1Copilot.devMode": true,
"m1Copilot.environment": "UAT"
}
PRD (default — no settings required):
{
"m1Copilot.environment": "PRD"
}
The extension computes the redirect URI as
${vscode.env.uriScheme}://Meridai.m1-copilot-client/auth-callback
and tunnels it via vscode.env.asExternalUri for Remote SSH /
Codespaces / web hosts.
Refresh tokens are persisted via ExtensionContext.secrets, which is
backed by the macOS Keychain, Windows Credential Vault, or
libsecret/KWallet on Linux (NFR-5). The access token is held only in
memory. Run M1 Copilot: Sign Out from the command palette to clear
stored credentials.
Open gates / known gaps
- W1 (canonical-JSON sub-spec) — the digest serialization used here
(
canonicalJson in src/companion/executor.ts) is a sorted-keys,
no-whitespace JSON suitable for review, but it MUST be confirmed
against m1-agents before this scaffold interops with production.
- S2 (Keycloak SSO) — auth-code + PKCE flow is implemented; OS-keychain
refresh-token storage uses
ExtensionContext.secrets. ID-token signature
verification (JWKS-based) is a follow-up — today the access token is
verified server-side by m1-agents, and the ID token is only used to
read the email claim for display.
- Webview bundle — the React app in
src/webview/App.tsx is not yet
wired through Vite. The webview HTML is a placeholder proving the
postMessage channel works.
- Persistent buffer —
OfflineWatchdog defaults to in-memory storage.
For real offline survival across reloads, pass a BufferStorage
backed by ExtensionContext.workspaceState from extension.ts.
Scripts
npm run typecheck — tsc --noEmit
npm run lint — ESLint flat config with TypeScript parser
npm test — Vitest unit tests
npm run compile — emit out/