@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/README.md +77 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -0
- package/dist/client.js.map +1 -1
- package/dist/device/index.d.ts +407 -0
- package/dist/device/index.d.ts.map +1 -0
- package/dist/device/index.js +223 -0
- package/dist/device/index.js.map +1 -0
- package/dist/errors.d.ts +53 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +66 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/resources/devices.d.ts +169 -0
- package/dist/resources/devices.d.ts.map +1 -0
- package/dist/resources/devices.js +133 -0
- package/dist/resources/devices.js.map +1 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.d.ts.map +1 -1
- package/openapi.yaml +476 -0
- package/package.json +5 -1
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.
|
|
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": [
|