@sema-agent/sdk 9.7.1 → 9.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -5127,6 +5127,283 @@ paths:
5127
5127
  schema: { $ref: '#/components/schemas/ErrorResponse' }
5128
5128
  '501': { $ref: '#/components/responses/NotImplemented' } # capability.adoption_store_required
5129
5129
 
5130
+ # ─────────────────────────────────────────────────────────────────────────────────────────────
5131
+ # device lane admin face (server >= 7.88.0; wire contract §14. O4/S-4 shipped the first three at
5132
+ # 7.55.0, S-470 completed the six at 7.88.0).
5133
+ #
5134
+ # 🔴 PRESENCE IS ANSWERED BY THE ROUTE ITSELF: on a non-device deployment (`REMOTE_EXEC != device`,
5135
+ # or no SQL store) ALL SIX verbs answer 501 `capability.device_lane_required`. Predict it from
5136
+ # `capabilities.deviceExecutor.management` (PRESENT-IFF — absent means a pre-7.88.0 peer, so probe).
5137
+ #
5138
+ # 🔴 ANTI-ENUMERATION: `not_found.device` is ONE answer with FIVE arms (binding absent / binding not
5139
+ # yours / target device absent / not in the binding's domain / revoked), byte-identical. Never branch on
5140
+ # its message and never infer existence from it.
5141
+ #
5142
+ # 🔴 AUTHZ (all six): owner domain = the VERIFIED `(tenant, subject)` composite identity (tenant from the
5143
+ # registry auth-bridge JWT `scope` claim — a signed source, NEVER a self-reported header). No tenant claim
5144
+ # and not an explicit operator ⇒ 403 `auth.forbidden` (loud, never a silent empty view).
5145
+ #
5146
+ # First-bind is NOT here: it is the submit-body top-level key `deviceId` (§14.6, see TaskRequest).
5147
+ # ─────────────────────────────────────────────────────────────────────────────────────────────
5148
+ /v1/devices:
5149
+ parameters:
5150
+ - $ref: '#/components/parameters/PrincipalHeader'
5151
+ get:
5152
+ tags: [devices]
5153
+ operationId: devicesList
5154
+ x-status: gated # server routes/devices.ts;需 device lane(REMOTE_EXEC=device + SQL 店),否则 501 capability.device_lane_required。SDK devices.list() 消费。
5155
+ summary: List the caller's devices (owner-scoped; an explicit operator sees every row).
5156
+ description: >
5157
+ 🔴 REVOKED ROWS ARE NOT LISTED. Revocation is terminal (re-enrolling yields a NEW deviceId), so listing
5158
+ them would only invite picking one as a rebind target and then hitting a 404. Therefore "absent from this
5159
+ list" does NOT mean "no such id" — it means "not a usable rebind target".
5160
+
5161
+ Each row's `status` is the SAME presence bit the submit-time admission chain judges: `offline` ⇒ a submit
5162
+ carrying that `deviceId` answers 409 `device.offline`. Presence is decided by THIS replica's hub having a
5163
+ live connection (device lane v1 is a single-replica deployment shape).
5164
+ responses:
5165
+ '200':
5166
+ description: The owner-scoped device list.
5167
+ content:
5168
+ application/json:
5169
+ schema: { $ref: '#/components/schemas/DeviceListResult' }
5170
+ '401': { $ref: '#/components/responses/Unauthorized' }
5171
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.forbidden — the credential carries no tenant claim and the caller is not an explicit operator
5172
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.device_lane_required
5173
+
5174
+ /v1/devices/enroll-tokens:
5175
+ parameters:
5176
+ - $ref: '#/components/parameters/PrincipalHeader'
5177
+ post:
5178
+ tags: [devices]
5179
+ operationId: devicesIssueEnrollToken
5180
+ x-status: gated # server routes/devices.ts;同族 501 谓词。SDK devices.issueEnrollToken() 消费。
5181
+ summary: Issue a one-shot enrollment token for the CALLER'S OWN composite identity.
5182
+ description: >
5183
+ The body is a CLOSED SHAPE WITH ZERO ACCEPTED KEYS (any key ⇒ 400 `request.body_shape`, naming the key
5184
+ path). There is no `target` key because the token is always issued for the caller itself: "issue on
5185
+ someone else's behalf" needs a "who may act for whom" authorization face, and v1 does not build one.
5186
+
5187
+ 🔴 CONSEQUENTLY AN EXPLICIT OPERATOR IS **NOT** EXEMPT FROM THE DOMAIN DOOR: an operator with no tenant
5188
+ claim answers the same 403 as any domainless credential. Silently issuing to the operator itself would
5189
+ write a composite identity nobody authorized into the devices row's owner.
5190
+
5191
+ 🔴 `token` IS PLAINTEXT ONCE — the store keeps only its sha256 digest. Lose it and you re-issue; there is
5192
+ no "recover". Do not log it, do not put it on a durable stream.
5193
+ `ttlSec` = the deployment knob `DEVICE_ENROLL_TOKEN_TTL_SEC` (default 900); `expiresAt` is computed FROM
5194
+ THE DATABASE'S now, not the caller's clock.
5195
+ requestBody:
5196
+ required: false
5197
+ content:
5198
+ application/json:
5199
+ schema:
5200
+ type: object
5201
+ additionalProperties: false
5202
+ description: 'Closed shape with ZERO accepted keys — send `{}` or no body at all. Any key ⇒ 400 request.body_shape naming it.'
5203
+ responses:
5204
+ '200':
5205
+ description: The issued token (plaintext ONCE).
5206
+ content:
5207
+ application/json:
5208
+ schema: { $ref: '#/components/schemas/DeviceEnrollToken' }
5209
+ '400': { $ref: '#/components/responses/BadRequest' } # request.body_shape — any key at all (zero-key closed shape); request.invalid_json
5210
+ '401': { $ref: '#/components/responses/Unauthorized' }
5211
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.forbidden — no tenant claim (NO operator exemption on this verb)
5212
+ '429': { $ref: '#/components/responses/RateLimited' } # limit.rate_exceeded + retry-after. 🔴 A rate CHECK THAT THROWS refuses, fail-closed (server `device-enrollment.ts` rateGate: the catch arm answers this code with retryAfterSec, it does not let the request through — that is exactly the moment token stuffing wants)
5213
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.device_lane_required
5214
+
5215
+ /v1/devices/enroll:
5216
+ parameters:
5217
+ - $ref: '#/components/parameters/PrincipalHeader'
5218
+ post:
5219
+ tags: [devices]
5220
+ operationId: devicesEnroll
5221
+ x-status: gated # server routes/devices.ts;同族 501 谓词。SDK devices.enroll() 消费。
5222
+ summary: Enroll one device with a one-shot token and the device's own public key.
5223
+ description: >
5224
+ 🔴 WHAT IS PRE-AUTH HERE IS THE **DEVICE**, NOT THE CALLER. The device has no deviceId and no key pair in
5225
+ the store yet, so this verb cannot hang a DEVICE credential door on the request — but the CALLER still
5226
+ must carry a verified composite identity (no tenant claim ⇒ 403 `auth.forbidden`). Letting a domainless
5227
+ caller through would replace the "the enrolling request's verified principal must match" defence with
5228
+ "whoever holds the token is that person".
5229
+
5230
+ 🔴 `deviceId` AND `owner` ARE NOT INPUTS: the server mints the former (`dev_<ulid>`), and the latter is
5231
+ always the verified composite identity. `pubkey` is generated BY THE DEVICE — the private key never goes
5232
+ on the wire; it is the device's only credential, and it signs the handshake payload built by
5233
+ `@sema-agent/sdk/device`'s `buildHelloSignaturePayload`.
5234
+
5235
+ 🔴 THE IDEMPOTENT REPLAY AND A REAL ENROLLMENT ARE WIRE-IDENTICAL (same 200, same `deviceId`): same token
5236
+ + SAME pubkey is the safe retry after a lost response; same token + a DIFFERENT pubkey is 403
5237
+ `device.enrollment_invalid` plus a `device_enroll_token_reuse` audit row. Being able to tell the two 200s
5238
+ apart would open a probe for "has this token been used", so they are deliberately identical — do not try
5239
+ to infer from the response whether you were first.
5240
+ requestBody:
5241
+ required: true
5242
+ content:
5243
+ application/json:
5244
+ schema: { $ref: '#/components/schemas/DeviceEnrollRequest' }
5245
+ responses:
5246
+ '200':
5247
+ description: 'The minted device id. Carries NO one-shot secret (the device''s credential is its own never-uploaded private key). Byte-identical for an idempotent replay.'
5248
+ content:
5249
+ application/json:
5250
+ schema: { $ref: '#/components/schemas/DeviceEnrollResult' }
5251
+ '400': { $ref: '#/components/responses/BadRequest' } # request.body_shape (unknown key / missing required); request.field_invalid (platformOs outside the v1 closed set `darwin|linux` — a third value is never silently accepted; a field over its byte column — a silently truncated value is a DIFFERENT value)
5252
+ '401': { $ref: '#/components/responses/Unauthorized' }
5253
+ '403':
5254
+ description: >
5255
+ `auth.forbidden` (the caller carries no tenant claim) OR `device.enrollment_invalid` — the LATTER is
5256
+ this verb's ONLY outward refusal code and it folds FOUR reasons (token absent / expired / bound to a
5257
+ different composite identity / already used by ANOTHER pubkey) into byte-identical bytes, message
5258
+ included. That is anti-enumeration, not vagueness: distinguishable reasons would hand token stuffing a
5259
+ "does this token exist / has it been used" probe. The sub-reasons go only to `device_audit`.
5260
+ Action: ask the issuer for a new token.
5261
+ content:
5262
+ application/json:
5263
+ schema: { $ref: '#/components/schemas/ErrorResponse' }
5264
+ '429': { $ref: '#/components/responses/RateLimited' } # limit.rate_exceeded + retry-after (the SAME limiter as the issue verb, keyed per verb — `issue:<tenant>\0<subject>` / `enroll:<tenant>\0<subject>`; a rate check that throws REFUSES, fail-closed)
5265
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.device_lane_required
5266
+
5267
+ /v1/devices/{deviceId}/revoke:
5268
+ parameters:
5269
+ - $ref: '#/components/parameters/PrincipalHeader'
5270
+ - { name: deviceId, in: path, required: true, schema: { type: string }, description: 'From DeviceSummary.deviceId. Byte-exact key: non-empty, no control characters or surrounding whitespace, at most 64 bytes (otherwise 400 request.id_invalid).' }
5271
+ post:
5272
+ tags: [devices]
5273
+ operationId: devicesRevoke
5274
+ x-status: gated # server routes/devices.ts;同族 501 谓词。SDK devices.revoke() 消费。
5275
+ summary: Revoke one device (terminal; idempotent).
5276
+ description: >
5277
+ The owner itself, or an explicit operator acting on THE ROW'S domain (an operator never mints a domain of
5278
+ its own into someone else's device row). Row absent / not yours ⇒ 404 `not_found.device`, SAME BYTES —
5279
+ an operator is no exception either: its fleet-wide visibility is provided by `GET /v1/devices`, and this
5280
+ verb does not take on a second existence answer.
5281
+
5282
+ IDEMPOTENT: revoking twice still answers 200, but writes no second audit row.
5283
+
5284
+ 🔴 REVOCATION IS TERMINAL and takes effect IMMEDIATELY on three faces:
5285
+ (1) live connection — invalidating the connection lease and bumping the connection generation happen in
5286
+ THE SAME store transaction, so the live socket receives `bye:revoked` and drops, and in-flight
5287
+ instructions settle as outcome-unknown (design window: within 5 s);
5288
+ (2) every later submit on that session = 403 `device.revoked`;
5289
+ (3) re-enrolling yields a NEW `deviceId` — the old row never comes back (replaying an old enrollment token
5290
+ is also 403 `device.revoked`).
5291
+ So do NOT use revoke as a "temporarily take it offline" knob: there is no inverse verb.
5292
+ requestBody:
5293
+ required: false
5294
+ content:
5295
+ application/json:
5296
+ schema: { $ref: '#/components/schemas/DeviceRevokeRequest' }
5297
+ responses:
5298
+ '200':
5299
+ description: 'The revocation receipt (same shape on an idempotent second revoke).'
5300
+ content:
5301
+ application/json:
5302
+ schema: { $ref: '#/components/schemas/DeviceRevokeResult' }
5303
+ '400': { $ref: '#/components/responses/BadRequest' } # request.path_malformed (bad percent-encoding); request.id_invalid (byte-exact key door); request.body_shape (unknown key); request.field_invalid (`reason` over 512 bytes — a truncated audit reason is a DIFFERENT reason)
5304
+ '401': { $ref: '#/components/responses/Unauthorized' }
5305
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.forbidden — no tenant claim and not an explicit operator
5306
+ '404': { $ref: '#/components/responses/NotFound' } # not_found.device — five arms, same code same string (anti-enumeration)
5307
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.device_lane_required
5308
+
5309
+ /v1/devices/sessions/{rootSessionId}:
5310
+ parameters:
5311
+ - $ref: '#/components/parameters/PrincipalHeader'
5312
+ - { name: rootSessionId, in: path, required: true, schema: { type: string }, description: 'The ROOT session id (the binding key; descendants inherit). Byte-exact key: non-empty, no control characters or surrounding whitespace, at most 128 bytes (otherwise 400 request.id_invalid).' }
5313
+ get:
5314
+ tags: [devices]
5315
+ operationId: devicesSessionBinding
5316
+ x-status: gated # server routes/devices.ts;同族 501 谓词。SDK devices.sessionBinding() 消费。
5317
+ summary: Read one root session's device binding (all the material a shell's rebind UX needs).
5318
+ description: >
5319
+ `boundDevice.rev` is exactly the prior state the rebind verb's `expectedRev` must echo — READ THIS FIRST,
5320
+ then rebind. Unbound / not yours ⇒ 404 `not_found.device`, SAME code SAME string (anti-enumeration: "no
5321
+ visible binding" is one answer; it does not tell you whether the session exists).
5322
+ responses:
5323
+ '200':
5324
+ description: The binding.
5325
+ content:
5326
+ application/json:
5327
+ schema: { $ref: '#/components/schemas/DeviceSessionBinding' }
5328
+ '400': { $ref: '#/components/responses/BadRequest' } # request.path_malformed / request.id_invalid
5329
+ '401': { $ref: '#/components/responses/Unauthorized' }
5330
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.forbidden
5331
+ '404': { $ref: '#/components/responses/NotFound' } # not_found.device — unbound OR not yours, byte-identical
5332
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.device_lane_required
5333
+
5334
+ /v1/devices/sessions/{rootSessionId}/rebind:
5335
+ parameters:
5336
+ - $ref: '#/components/parameters/PrincipalHeader'
5337
+ - { name: rootSessionId, in: path, required: true, schema: { type: string }, description: 'The ROOT session id whose binding is being moved.' }
5338
+ post:
5339
+ tags: [devices]
5340
+ operationId: devicesSessionRebind
5341
+ x-status: gated # server routes/devices.ts;同族 501 谓词 + 无活跃 run 门需 run store(501 capability.run_store_required)。SDK devices.rebind() 消费。
5342
+ summary: Explicitly rebind a root session to another device (v1 has no implicit rebind).
5343
+ description: >
5344
+ `expectedRev` IS REQUIRED and must be the rev read from `GET /v1/devices/sessions/{rootSessionId}`: an
5345
+ explicit prior state, so two concurrent operators cannot silently stomp each other. Door order (frozen,
5346
+ five steps server-side):
5347
+ (1) owner door — a non-owner gets 404, SAME bytes;
5348
+ (2) target-device door — `toDeviceId` must exist, belong to THE BINDING'S domain (an operator may not
5349
+ rebind across tenants) and not be revoked; otherwise 404, SAME bytes;
5350
+ (3) NO-ACTIVE-RUN door, judged BEFORE the CAS, with the durable criterion = the run store's session claim
5351
+ (parked runs included) ⇒ 409 `device.rebind_active_run` carrying `activeTaskId`; no run store wired ⇒ 501
5352
+ `capability.run_store_required` (a door with no criterion refuses);
5353
+ (4) prior-state CAS in one store transaction plus a `session_rebound` audit row ⇒ rev mismatch is 409
5354
+ `device.rebind_rev_conflict` carrying `currentRev`. 🔴 The SAME `toDeviceId` with a WRONG rev is still a
5355
+ 409 — a wrong prior state is wrong;
5356
+ (5) winner ⇒ 200.
5357
+
5358
+ 🔴 THE REFUSAL BODIES ARE FLAT, NOT NESTED: `activeTaskId` / `currentRev` are TOP-LEVEL keys alongside
5359
+ `error` / `errorCode` (`sendError(res, status, code, message, extra)` spreads `extra` at the top level).
5360
+ Read `body.activeTaskId` / `body.currentRev` directly — do not look for a `details` wrapper.
5361
+
5362
+ IDEMPOTENT FORM: `toDeviceId` == the current binding AND `expectedRev` == the current rev ⇒ 200 that
5363
+ echoes back WITHOUT incrementing `rev` and writes no audit row.
5364
+
5365
+ ⚠️ WRITTEN-DOWN RESIDUAL (a design ruling, not an oversight): step 3 and step 4 are two operations on two
5366
+ stores, so a check→act window exists — a run that wins the session claim concurrently with the rebind is
5367
+ not stopped by this door. The window's failure direction is safe: that run's execution env is minted
5368
+ either against the NEW binding (identical to submitting one moment after the rebind) or against the old
5369
+ one — and then EVERY instruction it issues is refused `device.identity_mismatch` by the dispatch door's
5370
+ identity re-assertion (the hub's `expectedDeviceId` fence), loudly, with zero instructions landing. So do
5371
+ not run a rebind and a submit concurrently; rebind first, then submit (or re-read the binding after).
5372
+
5373
+ ⚠️ IN-FLIGHT INSTRUCTIONS ARE NOT MIGRATED: park redemption and late instructions are validated against
5374
+ the binding AS OF INSTRUCTION MINT TIME and are always refused `device.identity_mismatch` (403) — never
5375
+ silently re-routed to the new device, which does not have that workspace.
5376
+ requestBody:
5377
+ required: true
5378
+ content:
5379
+ application/json:
5380
+ schema: { $ref: '#/components/schemas/DeviceRebindRequest' }
5381
+ responses:
5382
+ '200':
5383
+ description: >
5384
+ The rebind receipt — SAME key set for a real rebind and for the idempotent form.
5385
+ 🔴 `workspaceCarryover` is the FROZEN LITERAL `"none"` on both: rebinding changes routing only, it
5386
+ does NOT move workspace bytes, and the shell MUST disclose "the new device starts from a fresh
5387
+ workspace" on the strength of this field.
5388
+ content:
5389
+ application/json:
5390
+ schema: { $ref: '#/components/schemas/DeviceRebindResult' }
5391
+ '400': { $ref: '#/components/responses/BadRequest' } # request.path_malformed / request.id_invalid / request.body_shape (body is not `{toDeviceId, expectedRev}`) / request.field_invalid (expectedRev not a non-negative integer; toDeviceId fails the byte-exact key door)
5392
+ '401': { $ref: '#/components/responses/Unauthorized' }
5393
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.forbidden
5394
+ '404': { $ref: '#/components/responses/NotFound' } # not_found.device — five arms, same code same string
5395
+ '409':
5396
+ description: >
5397
+ TWO errorCodes ride this status and the next action differs, so branch on `errorCode`:
5398
+ `device.rebind_active_run` (a non-terminal run holds this session; body carries the TOP-LEVEL
5399
+ `activeTaskId` — cancel it or wait for its terminal state, then retry) and
5400
+ `device.rebind_rev_conflict` (the prior-state CAS lost; body carries the TOP-LEVEL `currentRev` — one
5401
+ hop to relocate: re-read the binding, or resend with `currentRev`).
5402
+ content:
5403
+ application/json:
5404
+ schema: { $ref: '#/components/schemas/ErrorResponse' }
5405
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.device_lane_required, or capability.run_store_required (the no-active-run door needs a durable, cross-replica criterion)
5406
+
5130
5407
  /metrics/summary:
5131
5408
  get:
5132
5409
  tags: [metrics]
@@ -5877,6 +6154,38 @@ components:
5877
6154
  submit leg today — it belongs to callers that build a `TaskSpec` directly.
5878
6155
  additionalProperties: true
5879
6156
  cwd: { type: string, description: Working directory for the execution env. }
6157
+ deviceId:
6158
+ type: string
6159
+ description: >
6160
+ server >= 7.88.0 (S-470, wire contract §14.6) — DEVICE-LANE FIRST-BIND. "Run this on THAT machine of
6161
+ mine", same level and same trust posture as `cwd`: caller-supplied, server-gated. It is NOT an
6162
+ identity (identity always comes from the verified composite-identity chain), just a selector.
6163
+
6164
+ ROOT TASK ONLY: the binding key is the ROOT SESSION and descendants inherit unconditionally; the
6165
+ RESUME family (`/decide` `/answer` `/plan_review` `/wake`) does NOT accept this key — the lane is
6166
+ fixed per tree.
6167
+
6168
+ 🔴 A NON-DEVICE LANE ANSWERS 400 `request.precondition_unmet` (the message names `REMOTE_EXEC=device`).
6169
+ Deliberately NOT silently ignored: otherwise the caller believes it picked an execution device while
6170
+ that declaration never existed. Probe `capabilities.deviceExecutor` — `false` ⇒ do not send.
6171
+
6172
+ FIRST BIND = INSERT-if-absent CAS ⇒ exactly one concurrent winner. A loser asking for the SAME device
6173
+ is an idempotent success; a loser asking for a DIFFERENT device gets 409 `device.claim_conflict`.
6174
+ LATER ROOT SUBMITS: absent = keep the binding / same = idempotent (binding `rev` unchanged) /
6175
+ different = 403 `device.identity_mismatch`. There is NO implicit rebind in v1 — changing device is the
6176
+ explicit `POST /v1/devices/sessions/{rootSessionId}/rebind` action.
6177
+
6178
+ ADMISSION CHAIN (three steps, all fail-closed): resolve the binding (else 409 `device.not_bound`) →
6179
+ the devices row is `active` (missing / not yours = 404 `not_found.device`, same bytes; revoked = 403
6180
+ `device.revoked`) → presence is online (else 409 `device.offline`). ANY lookup failure = REFUSE.
6181
+
6182
+ 🔴 Every refusal above EXCEPT `device.claim_conflict` / `device.identity_mismatch` is answered BEFORE
6183
+ the server creates anything ⇒ no run row, no session claim, no durable trace. Observable on
6184
+ `POST /v1/runs`: after a caller-minted `taskId` hits `device.offline`, retrying with the SAME `taskId`
6185
+ once the device is online yields a REAL acceptance, not a replay of a failed receipt.
6186
+ ⚠️ The flip side, stated honestly: that caller-minted-`taskId` idempotent replay ALSO passes the
6187
+ admission chain, so during a brief executor outage the same `taskId` gets `409 device.offline` instead
6188
+ of the `202` receipt (the original run is unaffected — it takes the reconnect grace path).
5880
6189
  selfOrchestration: { type: boolean, description: Permit S8 self-orchestration (run_workflow) inside this task. }
5881
6190
  enableFork: { type: boolean, description: Permit session forking from inside this task. }
5882
6191
  # 原注是「形状归 SDK 的 TaskAgentDefinition 所有,这里刻意保持 OPEN 而不重复一遍」。2026-07-25 推翻该选择,
@@ -7390,6 +7699,14 @@ components:
7390
7699
  handler's mount condition. `wsPath` is deliberately on the wire (hard-coding it downstream
7391
7700
  would turn a path change into a cross-repo breaking change). Device COUNT / online state are
7392
7701
  deliberately NOT advertised (that is the `/v1/devices/*` admin face).
7702
+
7703
+ 🔴 `management` (server >= 7.88.0) answers a DIFFERENT question from the other three: those are the
7704
+ DEVICE-side join face (can an executor connect), this one is the SHELL-side admin face (are the six
7705
+ `/v1/devices/*` verbs mounted). Its predicate is byte-identical to that family's 501
7706
+ `capability.device_lane_required` criterion (store + hub + admission chain, all three wired by
7707
+ `boot/device-lane.ts`). PRESENT-IFF: the key's ABSENCE means the peer is a pre-7.88.0 server that
7708
+ cannot answer — probe by 501. Do NOT read absence as `false` (the dispositions differ: `false` = this
7709
+ server says the face is not here, so hide the entry; absent = unknown, one attempt is legitimate).
7393
7710
  oneOf:
7394
7711
  - type: boolean
7395
7712
  enum: [false]
@@ -7401,6 +7718,7 @@ components:
7401
7718
  protocolVersion: { type: integer }
7402
7719
  maxInflightPerDevice: { type: integer }
7403
7720
  wsPath: { type: string }
7721
+ management: { type: boolean, description: 'server >= 7.88.0. `/v1/devices/*` admin verbs present. PRESENT-IFF — absent means a pre-7.88.0 peer (unknown), not false.' }
7404
7722
  memoryEngine:
7405
7723
  description: >
7406
7724
  Memory ENGINE posture (capabilities.ts:210 = `projectMemoryEngineCapability`). Object = the
@@ -16271,3 +16589,161 @@ components:
16271
16589
  allOf: [{ $ref: '#/components/schemas/AdoptionReport' }]
16272
16590
  description: 'Frozen ONLY when `status` is `adopted`; on `stalled` it is a provisional projection of the current row — do not persist it as audit evidence.'
16273
16591
  current: { $ref: '#/components/schemas/AdoptionCurrent' }
16592
+
16593
+ # ── device lane admin face (server >= 7.88.0; wire contract §14) ─────────────────────────────
16594
+ # Shapes taken verbatim from the server's `sendJson` mint points in `src/http/routes/devices.ts`
16595
+ # (read off the published bytes, not off a changelog). Consumed by `client.devices.*`.
16596
+ # 🔴 The EXECUTOR-side protocol face (closed-set vocabularies, both directions' frames, the handshake
16597
+ # signature bytes) is NOT here and NOT on this wire — it is the package subpath `@sema-agent/sdk/device`,
16598
+ # mirroring `@sema-agent/server/device-protocol`.
16599
+ DeviceSummary:
16600
+ type: object
16601
+ description: 'One row of the device list (server `deviceListRow`; frozen key set).'
16602
+ additionalProperties: false
16603
+ required: [deviceId, status]
16604
+ properties:
16605
+ deviceId: { type: string, minLength: 1 }
16606
+ name: { type: string, description: 'The device displayName. HONESTLY ABSENT when empty (server: `displayName !== "" ? {name} : {}`) — absence reads as "this device has no name", not "it could not be read".' }
16607
+ status:
16608
+ type: string
16609
+ enum: [online, offline]
16610
+ description: >
16611
+ Presence, decided by THIS replica's hub having a live connection (device lane v1 = a single-replica
16612
+ deployment shape). This is the SAME bit the submit-time admission chain judges: `offline` ⇒ a submit
16613
+ carrying that `deviceId` answers 409 `device.offline`.
16614
+ lastSeenAt: { type: string, format: date-time, description: 'ISO-8601. ABSENT if the device has never connected (not `null`, not epoch 0).' }
16615
+
16616
+ DeviceListResult:
16617
+ type: object
16618
+ description: >
16619
+ The `GET /v1/devices` 200 body. Owner-domain filtered; an explicit operator sees every row.
16620
+ 🔴 REVOKED ROWS ARE NOT LISTED (revocation is terminal; re-enrolling yields a new deviceId), so "absent
16621
+ from this list" means "not a usable rebind target", NOT "no such id".
16622
+ additionalProperties: false
16623
+ required: [devices]
16624
+ properties:
16625
+ devices:
16626
+ type: array
16627
+ items: { $ref: '#/components/schemas/DeviceSummary' }
16628
+
16629
+ DeviceBinding:
16630
+ type: object
16631
+ description: 'One session↔device binding (server `boundDeviceOf`; frozen key set).'
16632
+ additionalProperties: false
16633
+ required: [deviceId, rev, boundAt]
16634
+ properties:
16635
+ deviceId: { type: string, minLength: 1 }
16636
+ rev: { type: integer, minimum: 0, description: 'The prior-state version — this is exactly what the rebind verb''s `expectedRev` must echo (an explicit prior state, so two concurrent operators cannot silently stomp each other).' }
16637
+ boundAt: { type: string, format: date-time, description: 'ISO-8601.' }
16638
+
16639
+ DeviceSessionBinding:
16640
+ type: object
16641
+ description: 'The `GET /v1/devices/sessions/{rootSessionId}` 200 body (all the material a shell''s rebind UX needs).'
16642
+ additionalProperties: false
16643
+ required: [rootSessionId, boundDevice]
16644
+ properties:
16645
+ rootSessionId: { type: string, minLength: 1 }
16646
+ boundDevice: { $ref: '#/components/schemas/DeviceBinding' }
16647
+
16648
+ DeviceRebindRequest:
16649
+ type: object
16650
+ description: 'The `POST /v1/devices/sessions/{rootSessionId}/rebind` body. NOTE: server 7.88.2 does NOT key-close this one body (unknown keys are dropped silently, unlike enroll/revoke which answer 400 request.body_shape); a server-side ticket tracks closing it. This SDK never sends extra keys.'
16651
+ additionalProperties: false
16652
+ required: [toDeviceId, expectedRev]
16653
+ properties:
16654
+ toDeviceId: { type: string, minLength: 1, description: 'Must exist, belong to THE BINDING''S domain (an operator may not rebind across tenants) and not be revoked — otherwise 404 not_found.device, same bytes.' }
16655
+ expectedRev:
16656
+ type: integer
16657
+ minimum: 0
16658
+ description: >
16659
+ REQUIRED — the rev read from `GET /v1/devices/sessions/{rootSessionId}`. 🔴 The same `toDeviceId` with a
16660
+ WRONG rev is STILL a 409 `device.rebind_rev_conflict`: a wrong prior state is wrong. Not a non-negative
16661
+ integer ⇒ 400 request.field_invalid.
16662
+
16663
+ DeviceRebindResult:
16664
+ type: object
16665
+ description: >
16666
+ The rebind 200 body — the SAME key set for a real rebind and for the idempotent form (`toDeviceId` == the
16667
+ current binding AND `expectedRev` == the current rev ⇒ 200 that echoes back WITHOUT incrementing `rev` and
16668
+ writes no audit row).
16669
+ additionalProperties: false
16670
+ required: [rootSessionId, deviceId, rev, workspaceCarryover]
16671
+ properties:
16672
+ rootSessionId: { type: string, minLength: 1 }
16673
+ deviceId: { type: string, minLength: 1 }
16674
+ rev: { type: integer, minimum: 0 }
16675
+ workspaceCarryover:
16676
+ type: string
16677
+ enum: [none]
16678
+ description: >
16679
+ 🔴 A FROZEN LITERAL, present on BOTH 200 shapes: rebinding changes routing only, it does NOT move
16680
+ workspace bytes. The shell MUST disclose "the new device starts from a fresh workspace" on the strength
16681
+ of this field. There is no second value today; the single-member enum is because this is a DECLARATION,
16682
+ not a knob.
16683
+
16684
+ DeviceEnrollToken:
16685
+ type: object
16686
+ description: 'The `POST /v1/devices/enroll-tokens` 200 body.'
16687
+ additionalProperties: false
16688
+ required: [token, expiresAt, ttlSec]
16689
+ properties:
16690
+ token: { type: string, minLength: 1, description: '🔴 PLAINTEXT ONCE — the store keeps only its sha256 digest. Lose it and you re-issue; there is no "recover". Do not log it, do not put it on a durable stream.' }
16691
+ expiresAt: { type: string, format: date-time, description: 'ISO-8601, computed FROM THE DATABASE''S now (not the caller''s clock).' }
16692
+ ttlSec: { type: integer, minimum: 1, description: 'The deployment knob `DEVICE_ENROLL_TOKEN_TTL_SEC` (default 900).' }
16693
+
16694
+ DeviceEnrollRequest:
16695
+ type: object
16696
+ description: >
16697
+ The `POST /v1/devices/enroll` body — a CLOSED SHAPE (any key outside this set ⇒ 400 `request.body_shape`
16698
+ naming the key path).
16699
+ 🔴 `deviceId` and `owner` ARE NOT INPUTS: the server mints the former (`dev_<ulid>`) and the latter is
16700
+ always the VERIFIED composite identity.
16701
+ additionalProperties: false
16702
+ required: [token, pubkey, platformOs, workspaceRoot]
16703
+ properties:
16704
+ token: { type: string, minLength: 1, maxLength: 256, description: 'The one-shot plaintext token from the issue verb. Empty or over 256 chars ⇒ 400 request.field_invalid (it only feeds a sha256, but an unbounded string turns digesting into an amplification face).' }
16705
+ pubkey: { type: string, minLength: 1, description: 'The Ed25519 PUBLIC key the DEVICE generated. The private key NEVER goes on the wire — it is the device''s only credential, and it signs the payload built by `@sema-agent/sdk/device`''s buildHelloSignaturePayload.' }
16706
+ platformOs:
16707
+ type: string
16708
+ enum: [darwin, linux]
16709
+ description: 'The v1 CLOSED SET. A third value (e.g. `win32`) ⇒ 400 request.field_invalid — never silently accepted.'
16710
+ platformArch: { type: string, maxLength: 32, description: 'At most 32 **UTF-8 bytes** (producer gates byte length; `maxLength` is the looser character bound, see the unit convention).' }
16711
+ displayName: { type: string, maxLength: 255, description: 'Echoed back by `GET /v1/devices` as `name`, and HONESTLY ABSENT there when empty. At most 255 **UTF-8 bytes** (producer gates byte length; `maxLength` is the looser character bound).' }
16712
+ workspaceRoot: { type: string, minLength: 1, maxLength: 1024, description: 'The device-side root every instruction resolves against. Required and non-empty. At most 1024 **UTF-8 bytes** (producer gates byte length; `maxLength` is the looser character bound) ⇒ over it 400 request.field_invalid (a silently truncated value is a DIFFERENT value).' }
16713
+
16714
+ DeviceEnrollResult:
16715
+ type: object
16716
+ description: >
16717
+ The `POST /v1/devices/enroll` 200 body. It carries NO one-shot secret — the device's credential is the
16718
+ private key it generated and never uploaded.
16719
+ 🔴 THE IDEMPOTENT REPLAY IS WIRE-IDENTICAL to a real enrollment (same 200, same `deviceId`): same token +
16720
+ SAME pubkey is the safe retry after a lost response; same token + a DIFFERENT pubkey is 403
16721
+ `device.enrollment_invalid`. Telling the two 200s apart would open a "has this token been used" probe, so
16722
+ they are deliberately identical — do not infer from the response whether you were first.
16723
+ additionalProperties: false
16724
+ required: [deviceId]
16725
+ properties:
16726
+ deviceId: { type: string, minLength: 1, description: 'Server-minted, `dev_<ulid>`.' }
16727
+
16728
+ DeviceRevokeRequest:
16729
+ type: object
16730
+ description: 'The `POST /v1/devices/{deviceId}/revoke` body (closed shape; the only accepted key is `reason`). Absent body is folded to `{}` server-side.'
16731
+ additionalProperties: false
16732
+ properties:
16733
+ reason: { type: string, maxLength: 512, description: 'At most 512 **UTF-8 bytes** (the producer gates `Buffer.byteLength(reason, "utf8")`, NOT `.length` — this is the one family where the file''s unit convention''s explicit-UTF-8 carve-out applies); `maxLength: 512` is the deliberately LOOSER character bound (a multi-byte value can pass this schema and still get 400 request.field_invalid naming the field — loud, never a client-side false refusal). A silently truncated audit reason is a DIFFERENT reason.' }
16734
+
16735
+ DeviceRevokeResult:
16736
+ type: object
16737
+ description: >
16738
+ The revoke 200 body. IDEMPOTENT: revoking twice still answers 200, but writes no second audit row.
16739
+ 🔴 Revocation is TERMINAL and immediate on three faces: the live socket gets `bye:revoked` and drops (lease
16740
+ invalidation + connection-generation bump are in the SAME store transaction; in-flight instructions settle
16741
+ as outcome-unknown, design window 5 s); every later submit on that session is 403 `device.revoked`; and
16742
+ re-enrolling yields a NEW deviceId — the old row never comes back. There is NO inverse verb, so do not use
16743
+ revoke as "temporarily take it offline".
16744
+ additionalProperties: false
16745
+ required: [deviceId, status]
16746
+ properties:
16747
+ deviceId: { type: string, minLength: 1 }
16748
+ status: { type: string, enum: [revoked], description: 'Single member: revocation is a terminal state, there is no other answer.' }
16749
+ revokedAt: { type: string, format: date-time, description: 'ISO-8601; HONESTLY ABSENT when the store has no timestamp (server: `revokedAtMs !== null ? {revokedAt} : {}`).' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/sdk",
3
- "version": "9.7.1",
3
+ "version": "9.8.1",
4
4
  "description": "Typed, zero-runtime-dependency SDK for the Sema agent fleet usage plane. The shared substrate for all doors (CC/Codex MCP façade + web). Server-side only — tokens never enter the browser.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -28,6 +28,10 @@
28
28
  "types": "./dist/registry/index.d.ts",
29
29
  "import": "./dist/registry/index.js"
30
30
  },
31
+ "./device": {
32
+ "types": "./dist/device/index.d.ts",
33
+ "import": "./dist/device/index.js"
34
+ },
31
35
  "./registry-openapi.yaml": "./registry-openapi.yaml"
32
36
  },
33
37
  "files": [