Repository navigation
Ask Simlock who you are: identity.get, GET /v1/identity, simlock whoami #713
Description
Part of #701.
Scope
Anyone can ask Simlock who it thinks they are: the contract operation identity.get, served over HTTP as GET /v1/identity, on the CLI as simlock whoami, and in the typed client. It answers for a token, a socket session and a gateway uplink. The proxy answer comes in #714, which only fills the fields this task defines.
In short
A new question any caller may ask: "who am I?". It answers with the principal Simlock acts for, a name to show, the role, and how the caller signed in (via).
flowchart LR
C[CLI / HTTP / client] --> D["Dispatcher: identity.get (new)"]
D --> S[the caller's session]
$ simlock whoami # on the daemon's host, where the CLI can read admin.token
Principal: claude-1
Name: claude-1
Role: admin
Via: socket
$ simlock whoami --json
{"principal":"claude-1","name":"claude-1","role":"admin","via":"socket"}
$ curl -s -H "Authorization: Bearer slk_…" http://127.0.0.1:4700/v1/identity
{"principal":"tok_9f2c","name":"ci-runner","role":"agent","via":"token"}
| Caller | principal |
name |
role |
via |
|---|---|---|---|---|
socket, no credential, declared claude-1 |
claude-1 |
claude-1 |
agent |
socket |
| socket, admin secret | the declared principal | same | admin |
socket |
socket, operator token (--token slk_…) |
the declared principal | same | admin |
socket |
HTTP, agent token tok_9f2c labelled ci-runner |
tok_9f2c |
ci-runner |
agent |
token |
HTTP, operator token tok_1a2b, no label |
tok_1a2b |
tok_1a2b |
admin |
token |
| a gateway's uplink session on a worker | gw:<gateway id> |
gw:<gateway id> |
admin |
uplink |
If this goes wrong, a user would see the wrong name or role, or a whoami that fails for some callers.
Technical spec
Facts
Commit ccb50353.
| # | Fact | Source |
|---|---|---|
| F1 | Operations are defineOperation({ name, role, effect, input, output }) and listed in a name map |
src/contract/operations.ts:687-693,1020,1039 |
| F2 | DispatchSession is { principal, role, isGatewayUplink?, … } |
src/daemon/dispatch.ts:56-58 |
| F3 | A socket session's principal is the one declared at hello, else the daemon's default requester id | src/daemon/server.ts:914 |
| F4 | An uplink session is accepted with role forced admin and principal gw:<gateway instance id> |
src/daemon/server.ts:653-655; src/daemon/main.ts:763 |
| F5 | HTTP builds the session from the identity: principal = token id, operator → admin |
src/http/dispatcher-session.ts:24-26,74-77 |
| F6 | Each dispatcher maps every operation to a handler ("token.list": this.#tokenList) |
src/daemon/dispatcher.ts:293; src/gateway/dispatcher.ts:158 |
| F7 | The gateway may import from src/daemon only dispatch.ts |
.dependency-cruiser.mjs:152 |
| F8 | An HTTP route dispatches with buildHttpSession(c.get("identity")) behind agentAuth |
src/http/app.ts:626-628 |
| F9 | CLI commands are lazy nodes in src/cli/tree.ts; token loads commands/token.ts |
src/cli/tree.ts:84-91 |
| F10 | The typed client has one method per operation (#call("token.list", {})) |
src/simlock-client/client.ts:659 |
| F11 | Protocol range is { min: 39, max: 39 }; every contract shape change bumps it by one |
src/contract/protocol.ts:13; docs/internal/agent-rules/toolchain.md:156-158 |
| F12 | simlock status takes --json and then prints the object with writeResult; without it, labelled lines |
src/cli/commands/status.ts:12-21; src/cli/args.ts:30-31 |
| F13 | #712's Identity carries principal, name, role, via, ambient, signOutUrl? |
#701 Shared values |
| F14 | Every CLI command but daemon status/daemon stop and the history read connects with auto-launch, so it starts the daemon when none runs |
src/cli/connect.ts:44-45; src/cli/environment.ts:137,170; src/cli/commands/daemon.ts:39,52 |
| F15 | Modules get a second session shape, ModuleDispatchSession { principal, role, manageEventSubscription }, and main.ts passes the HTTP dispatch where a ModuleDispatch is expected |
src/modules/types.ts:66-76,90-91; src/daemon/main.ts:606-616,913-924 |
| F16 | On a worker every operation but hello, status.get and daemon.stop waits for startup to finish |
src/daemon/dispatch.ts:322; src/daemon/AGENTS.md Rules |
| F17 | The socket server switches on the operation name; an unknown one answers UNKNOWN_REQUEST |
src/daemon/server.ts:1118,1175-1176 |
| F18 | A real uplink session can be built in a test | src/daemon/test-relay-support.ts:199 |
Modules touched
Searches: rg '"token.list"' (every place one operation is wired: contract and its tests' role and effect tables, both dispatchers, the socket switch, HTTP, client, CLI), rg 'DispatchSession|ModuleDispatchSession', rg 'manageEventSubscription:' src -g '!*.test.ts' (every place a session object is built: src/http/app.ts:694, src/daemon/server.ts:1197, src/http/dispatcher-session.ts:75, src/daemon/test-relay-support.ts:24).
flowchart LR
CLI["cli: whoami (new)"] --> CL["simlock-client: identity() (new)"]
CL --> SV[daemon server]
H["http: GET /v1/identity (new)"] --> DD
SV --> DD["daemon dispatch.ts: identity handler (new)"]
GD["gateway dispatcher (changed)"] --> DD
DD --> CT["contract: identity.get (new)"]
src/contract/operations.ts,src/contract/operations.test.ts(role and effect tables),src/contract/protocol.ts.src/daemon/dispatch.ts(session fields, the shared handler),src/daemon/dispatcher.ts,src/daemon/server.ts(socket and uplink sessions setvia; the socket switch gains the case).src/modules/types.ts:ModuleDispatchSessiongainsviaandname?, so the HTTP dispatch still fitsModuleDispatch(F15).src/gateway/dispatcher.ts(maps the operation to the shared handler).src/http/dispatcher-session.ts,src/http/app.ts.src/simlock-client/client.ts(identity()onSimlockClient, so the admin client has it too),types.ts(IdentityOutput), re-exported fromsrc/client/index.ts;src/cli/tree.ts, newsrc/cli/commands/whoami.ts.- Docs:
docs/HTTP-API.md,docs/CLI.md,docs/CLIENT.md.
Contract and event changes
- New operation
identity.get: roleagent(any caller), effectread, input{}, output{ principal: string, name: string, role: "admin" | "agent", via: "token" | "proxy" | "socket" | "uplink", signOutUrl?: string }.proxyandsignOutUrlare never produced by this task; Sign in through a sign-in proxy's signed JWT #714 produces them. DispatchSessiongainsname?: string,via: "token" | "proxy" | "socket" | "uplink",signOutUrl?: string.nameabsent means the principal.Identity.viais a string (the strategy's name);buildHttpSessionparses it against this enum, and the registry test below keeps the two in step.- This PR bumps the protocol by one.
- No event.
Other code on the same state
State: the session object. Search: as in Modules touched (manageEventSubscription:): the socket accept and the uplink accept (server.ts), buildHttpSession, the events-stream session (app.ts:694), and the test relay. Each sets via (socket, uplink, the identity's via); none changes principal or role. No other task writes the session except #714, which passes a proxy identity through buildHttpSession.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
GET /v1/identity |
no or bad token | 401 as every route (#712) | none |
| same | worker token |
403 as every route | none |
simlock whoami |
daemon not running | starts it, as every command but daemon status/stop does (F14), then answers |
the CLI's usual launch limit |
identity.get on a worker |
daemon still starting | waits for startup, as every operation but hello, status.get, daemon.stop (F16) |
the daemon's usual startup parking |
identity.get over a protocol-39 socket |
older daemon | UNKNOWN_REQUEST is impossible: the hello's range already refuses the mismatch |
the hello |
State: none written.
Rules in play
- ADR 0003 §2, one contract behind every frontend:
identity.getis an operation, and HTTP, CLI and client render it. - ADR 0003 §4, "the principal is declared once at hello": the socket answer is the declared principal, as Simlock sees it.
- Architecture rule 10, one place: one handler in
dispatch.ts, used by both dispatchers. toolchain.md, a shape change bumps the protocol by one.
Tests
Seam: the HTTP app (createHttpApp) with the real shared handler registered on its FakeDispatcher, the daemon socket, and a real uplink session (F18); end to end in e2e/token-route.test.ts's style.
src/daemon/server.test.ts: over a real socket,identity.getanswers the declared principal as principal and name,via: "socket", roleagentwith no credential,adminwith the admin secret,adminwith an operator token.src/daemon(withtest-relay-support.ts): over a real uplink session,identity.getanswersgw:<id>,admin,via: "uplink".src/gateway/dispatcher.test.ts: the gateway answersidentity.getfrom its own caller's session, the same shape.src/http/app.test.ts(the real handler fromdispatch.tsregistered):GET /v1/identitywith an agent token labelledci-runneranswers principal = token id, nameci-runner, roleagent, viatoken; with an unlabelled operator token, name = token id, roleadmin; with no token, 401 "Missing bearer token".e2e/identity.test.ts(new, modelled one2e/token-route.test.ts): against a real daemon,simlock whoamiprints the four labelled lines for the CLI's own session, and--jsonprints the same as one object;GET /v1/identitywith a minted agent token answers that token's id; with aworkertoken, 403.e2e/contract-surface.test.tspasses withidentity.getadded (every operation has a socket case).e2e/identity.test.ts: the same two checks against a gateway answer the gateway's own sessions.src/simlock-clienttest:identity()sendsidentity.getand returns its output.src/daemon/dispatcher.test.ts: the shared handler copiessignOutUrlfrom a session that has one into its output, and omits it otherwise.src/auth(beside the conformance suite): every registered strategy'snameis a value ofidentity.get'sviaenum. The contract cannot importsrc/auth, so this test keeps the hand-written enum and the registry in step (architecture rule 20).
Done when
GET /v1/identity,simlock whoamiandsimlock whoami --jsonanswer as the table in In short, against a worker and a gateway, and over an uplink.- The protocol range is one above
main's at the time of merge. - The tests above pass;
pnpm test:e2e e2e/identity.test.ts e2e/token-route.test.ts e2e/contract-surface.test.tspasses. docs/HTTP-API.md,docs/CLI.mdanddocs/CLIENT.mddescribe the route, the command and the method.
Out of scope
- The proxy answer and
signOutUrl(Sign in through a sign-in proxy's signed JWT #714). - An MCP tool for it.
Depends on
Written by an agent.
Activity
feat: Ask Simlock who you are, over the socket, HTTP, the CLI and the…
chore: keep fallow quiet about identity.get's operation and the MCP f…
fix: derive via from the contract and list whoami and identity.get in…
feat(identity): identity.get, GET /v1/identity and simlock whoami (#735)
feat(auth): sign in through a sign-in proxy's signed JWT (#736)
test(real-proxy): run Simlock behind oauth2-proxy and Dex in CI (#740)