teamclaws 1.0.3 → 2.0.0

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.
@@ -0,0 +1,150 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { API_V2_BASE, OAUTH_TOKEN_URL } from "./constants.js";
3
+ import { TeamClawsError } from "./errors.js";
4
+ import { errorFromResponse, requestJson, } from "./http.js";
5
+ import { OsMachineKeyProvider, jwkThumbprint, } from "./machine-key.js";
6
+ import { isObject } from "./validation.js";
7
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
8
+ export class TargetAuthorization {
9
+ fetcher;
10
+ store;
11
+ keys;
12
+ now;
13
+ constructor(store, fetcher = fetch, keys = new OsMachineKeyProvider(store.root), now = Date.now) {
14
+ this.store = store;
15
+ this.fetcher = fetcher;
16
+ this.keys = keys;
17
+ this.now = now;
18
+ }
19
+ async requireGrant() {
20
+ const grant = await this.store.readAuthorization();
21
+ if (!grant)
22
+ throw new TeamClawsError("login_required", "Approve this CLI at TeamClaws with `teamclaws login` before using a workspace.", { exitCode: 3 });
23
+ return grant;
24
+ }
25
+ async accountHeadersFor(method, url) {
26
+ return this.accountHeadersForGrant(await this.requireGrant(), method, url);
27
+ }
28
+ async accountHeadersForGrant(grant, method, url) {
29
+ if (new URL(url).origin !== new URL(API_V2_BASE).origin)
30
+ throw new TeamClawsError("invalid_target", "A TeamClaws issuer URL is required.", { exitCode: 9 });
31
+ return {
32
+ "X-TeamClaws-Authorization-Id": grant.authorization_id,
33
+ DPoP: await this.dpop(grant.machine_key, method, url),
34
+ };
35
+ }
36
+ assertPairing(grant, credential) {
37
+ if (credential.version !== 2 ||
38
+ credential.requester_sub !== grant.sub ||
39
+ credential.authorization_id !== grant.authorization_id ||
40
+ credential.machine_jkt !== grant.machine_key.jkt ||
41
+ credential.pairing_id !== credential.device_id) {
42
+ throw new TeamClawsError("re_pair_required", "This pairing is not bound to the current TeamClaws authorization. Pair this workspace again.", { exitCode: 3 });
43
+ }
44
+ }
45
+ async headersFor(method, url, credential, signal) {
46
+ const grant = await this.requireGrant();
47
+ this.assertPairing(grant, credential);
48
+ if (new URL(url).origin !== credential.origin)
49
+ throw new TeamClawsError("target_mismatch", "The request target does not match the selected pairing.", { exitCode: 9 });
50
+ const pairingAuthorization = `Bearer ${credential.device_id}:${credential.device_token}`;
51
+ const exchangeUrl = `${API_V2_BASE}authorizations/${grant.authorization_id}/proofs/`;
52
+ const exchange = await requestJson(this.fetcher, exchangeUrl, {
53
+ method: "POST",
54
+ headers: {
55
+ DPoP: await this.dpop(grant.machine_key, "POST", exchangeUrl),
56
+ },
57
+ body: {
58
+ server_id: credential.server_id,
59
+ pairing_id: credential.pairing_id,
60
+ scope: "paired:invoke",
61
+ },
62
+ ...(signal ? { signal } : {}),
63
+ });
64
+ if (exchange.status < 200 || exchange.status >= 300)
65
+ throw errorFromResponse(exchange, "target_proof_denied");
66
+ if (!isObject(exchange.body) ||
67
+ exchange.body.token_type !== "DPoP" ||
68
+ exchange.body.scope !== "paired:invoke" ||
69
+ typeof exchange.body.access_token !== "string" ||
70
+ exchange.body.access_token.length < 16 ||
71
+ exchange.body.access_token.length > 8192 ||
72
+ !Number.isInteger(exchange.body.expires_in) ||
73
+ exchange.body.expires_in < 1 ||
74
+ exchange.body.expires_in > 300)
75
+ throw new TeamClawsError("invalid_response", "TeamClaws returned an invalid target proof.", { exitCode: 9 });
76
+ const proof = exchange.body.access_token;
77
+ return {
78
+ Authorization: pairingAuthorization,
79
+ "X-TeamClaws-Target-Proof": proof,
80
+ DPoP: await this.dpop(grant.machine_key, method, url, proof, pairingAuthorization),
81
+ };
82
+ }
83
+ async dpop(key, method, url, targetProof, pairingAuthorization) {
84
+ const parsed = new URL(url);
85
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password)
86
+ throw new TeamClawsError("invalid_target", "A canonical HTTPS target is required.", { exitCode: 9 });
87
+ const header = { typ: "dpop+jwt", alg: "ES256", jwk: key.public_jwk };
88
+ const payload = {
89
+ htm: method.toUpperCase(),
90
+ htu: `${parsed.origin}${parsed.pathname}`,
91
+ iat: Math.floor(this.now() / 1000),
92
+ jti: randomUUID(),
93
+ };
94
+ if (targetProof !== undefined)
95
+ payload.ath = hashAscii(targetProof);
96
+ if (pairingAuthorization !== undefined)
97
+ payload.pairing_ath = hashAscii(pairingAuthorization);
98
+ const signingInput = `${base64Json(header)}.${base64Json(payload)}`;
99
+ return `${signingInput}.${await this.keys.sign(key, signingInput)}`;
100
+ }
101
+ async tokenDpop(key) {
102
+ return this.dpop(key, "POST", OAUTH_TOKEN_URL);
103
+ }
104
+ }
105
+ export async function requestTargetJson(fetcher, authorization, url, credential, options = {}) {
106
+ const method = options.method ?? "GET";
107
+ const attempt = async () => requestJson(fetcher, url, {
108
+ ...options,
109
+ headers: {
110
+ ...options.headers,
111
+ ...(await authorization.headersFor(method, url, credential, options.signal)),
112
+ },
113
+ });
114
+ const first = await attempt();
115
+ if (first.status === 401 &&
116
+ isObject(first.body) &&
117
+ first.body.error === "target_proof_expired")
118
+ return attempt();
119
+ return first;
120
+ }
121
+ export function parseDurableAuthorization(value) {
122
+ if (!isObject(value) ||
123
+ value.version !== 2 ||
124
+ typeof value.authorization_id !== "string" ||
125
+ !uuid.test(value.authorization_id) ||
126
+ typeof value.sub !== "string" ||
127
+ value.sub.length < 1 ||
128
+ value.sub.length > 255 ||
129
+ !isObject(value.machine_key) ||
130
+ !["windows-cng", "posix-file"].includes(String(value.machine_key.provider)) ||
131
+ typeof value.machine_key.name !== "string" ||
132
+ !/^teamclaws-cli-[0-9a-f-]{36}$/u.test(value.machine_key.name) ||
133
+ !isObject(value.machine_key.public_jwk) ||
134
+ value.machine_key.public_jwk.kty !== "EC" ||
135
+ value.machine_key.public_jwk.crv !== "P-256" ||
136
+ typeof value.machine_key.public_jwk.x !== "string" ||
137
+ typeof value.machine_key.public_jwk.y !== "string" ||
138
+ typeof value.machine_key.jkt !== "string")
139
+ throw new TeamClawsError("invalid_credential_file", "The saved CLI authorization is invalid.", { exitCode: 9 });
140
+ const key = value.machine_key;
141
+ if (key.jkt !== jwkThumbprint(key.public_jwk))
142
+ throw new TeamClawsError("invalid_credential_file", "The saved CLI authorization key is invalid.", { exitCode: 9 });
143
+ return value;
144
+ }
145
+ function hashAscii(value) {
146
+ return createHash("sha256").update(value, "ascii").digest("base64url");
147
+ }
148
+ function base64Json(value) {
149
+ return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
150
+ }
@@ -227,6 +227,12 @@ export function parsePairingCredential(value) {
227
227
  return {
228
228
  device_id: normalizeUuid(value.device_id, "device identifier"),
229
229
  device_token: requireString(value.device_token, "device token", 8_192),
230
+ pairing_id: normalizeUuid(value.pairing_id, "pairing identifier"),
231
+ server_id: normalizeUuid(value.server_id, "server identifier"),
232
+ origin: canonicalWorkspaceOrigin(value.origin),
233
+ requester_sub: requireString(value.requester_sub, "requester subject", 255),
234
+ authorization_id: normalizeUuid(value.authorization_id, "authorization identifier"),
235
+ machine_jkt: requireThumbprint(value.machine_jkt),
230
236
  ...(Object.hasOwn(value, "external_chat_capabilities")
231
237
  ? {
232
238
  external_chat_capabilities: value.external_chat_capabilities,
@@ -317,13 +323,13 @@ export function parseSafeUserResponse(value) {
317
323
  exitCode: 9,
318
324
  });
319
325
  }
320
- assertExactKeys(value, ["id", "email", "display_name", "expires_at", "token_expired"], [], "account response");
321
- if (value.token_expired !== false) {
326
+ assertExactKeys(value, ["id", "email", "display_name", "authorization_id", "authorization_status"], [], "account response");
327
+ if (value.authorization_status !== "active") {
322
328
  throw new TeamClawsError("invalid_response", "TeamClaws returned an invalid account response.", {
323
329
  exitCode: 9,
324
330
  });
325
331
  }
326
- requireIsoDate(value.expires_at, "access-token expiry");
332
+ normalizeUuid(value.authorization_id, "authorization identifier");
327
333
  return {
328
334
  id: normalizeUuid(value.id, "user identifier"),
329
335
  display_name: requireString(value.display_name, "display name", 200),
@@ -405,44 +411,70 @@ export function parseLoginCredential(value) {
405
411
  export function parseWorkspaceCredential(value) {
406
412
  if (!isObject(value))
407
413
  throw invalidCredentialFile();
414
+ if (value.version === 1)
415
+ throw legacyPairingError();
408
416
  assertExactKeys(value, [
409
417
  "version",
410
418
  "server_id",
411
419
  "origin",
412
420
  "display_name",
413
421
  "device_id",
422
+ "pairing_id",
414
423
  "device_token",
424
+ "requester_sub",
425
+ "authorization_id",
426
+ "machine_jkt",
415
427
  "created_at",
416
428
  ], [], "workspace credential file");
417
- if (value.version !== 1)
429
+ if (value.version !== 2)
418
430
  throw invalidCredentialFile();
419
431
  return {
420
- version: 1,
432
+ version: 2,
421
433
  server_id: normalizeUuid(value.server_id),
422
434
  origin: canonicalWorkspaceOrigin(value.origin),
423
435
  display_name: requireString(value.display_name, "display name", 120),
424
436
  device_id: normalizeUuid(value.device_id, "device identifier"),
437
+ pairing_id: normalizeUuid(value.pairing_id, "pairing identifier"),
425
438
  device_token: requireSecret(value.device_token),
439
+ requester_sub: requireString(value.requester_sub, "requester subject", 255),
440
+ authorization_id: normalizeUuid(value.authorization_id, "authorization identifier"),
441
+ machine_jkt: requireThumbprint(value.machine_jkt),
426
442
  created_at: requireIsoDate(value.created_at, "credential creation date"),
427
443
  };
428
444
  }
429
445
  export function parseStatusWorkspaceCredential(value, expectedServerId) {
430
- if (!isObject(value) || value.version !== 1)
446
+ if (!isObject(value))
447
+ throw invalidCredentialFile();
448
+ if (value.version === 1)
449
+ throw legacyPairingError();
450
+ if (value.version !== 2)
431
451
  throw invalidCredentialFile();
432
452
  const serverId = normalizeUuid(value.server_id);
433
453
  if (serverId !== normalizeUuid(expectedServerId))
434
454
  throw invalidCredentialFile();
435
455
  return {
436
- version: 1,
456
+ version: 2,
437
457
  server_id: serverId,
438
458
  origin: canonicalWorkspaceOrigin(value.origin),
439
459
  display_name: typeof value.display_name === "string" && value.display_name.length > 0
440
460
  ? requireString(value.display_name, "display name", 120)
441
461
  : "Paired device",
442
462
  device_id: normalizeUuid(value.device_id, "device identifier"),
463
+ pairing_id: normalizeUuid(value.pairing_id, "pairing identifier"),
443
464
  device_token: requireSecret(value.device_token),
465
+ requester_sub: requireString(value.requester_sub, "requester subject", 255),
466
+ authorization_id: normalizeUuid(value.authorization_id, "authorization identifier"),
467
+ machine_jkt: requireThumbprint(value.machine_jkt),
444
468
  };
445
469
  }
470
+ function requireThumbprint(value) {
471
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/u.test(value))
472
+ throw invalidCredentialFile();
473
+ return value;
474
+ }
475
+ function legacyPairingError() {
476
+ return new TeamClawsError("re_pair_required", "This saved pairing predates bound CLI authorization. Pair this workspace again.", { exitCode: 3 });
477
+ }
446
478
  function requireSecret(value) {
447
479
  if (typeof value !== "string" || value.length < 8 || value.length > 8_192) {
448
480
  throw invalidCredentialFile();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamclaws",
3
- "version": "1.0.3",
3
+ "version": "2.0.0",
4
4
  "description": "Official TeamClaws command-line client and agent skills",
5
5
  "license": "PolyForm-Shield-1.0.0",
6
6
  "homepage": "https://teamclaws.com",
@@ -16,6 +16,7 @@ npx teamclaws login --json
16
16
  npx teamclaws login owner@example.com --json
17
17
  npx teamclaws auth status --json
18
18
  npx teamclaws logout --json
19
+ npx teamclaws logout --revoke --json
19
20
  ```
20
21
 
21
22
  Login uses OAuth 2.0 Device Authorization Grant against www.teamclaws.com. The
@@ -25,13 +26,15 @@ while the user signs in; do not restart it while awaiting approval. Confirm the
25
26
  returned account after `login_complete` and exit 0, not after opening a browser.
26
27
  Start without an email unless the user supplies one. Optional email is a bind
27
28
  sent to TeamClaws, not identity asserted by this process. Never transfer a
28
- model or harness token into this process. The command-line client stores its own
29
- protected login credential locally.
29
+ model or harness token into this process. The client stores an authorization ID
30
+ and protected local signing key; temporary OAuth tokens are discarded.
30
31
 
31
32
  Only treat the fixed structured events as safe. Never expose access tokens,
32
33
  refresh tokens, device codes, cookies, or authorization headers.
33
34
 
34
- Logout always removes the local command-line login credential. A
35
- `local_only_warning` status means local logout succeeded but remote revocation
36
- could not be confirmed. Completed Clawgate workspace credentials are not
37
- deleted by logout.
35
+ Ordinary logout removes the local authorization reference. Use `--revoke` only
36
+ when the user asks to revoke this CLI authorization at TeamClaws; the CLI waits
37
+ for confirmation before removing its local reference. If remote revocation is
38
+ uncertain, report that uncertainty and retain the local reference. Completed
39
+ Clawgate workspace credentials are not deleted by logout, but old pairings
40
+ without authorization binding require re-pairing.
@@ -33,8 +33,10 @@ failures; report the actual result without claiming success.
33
33
  ## Choose workspaces
34
34
 
35
35
  Run `teamclaws servers list --json`. Use only a successful `servers_list` event;
36
- errors are not empty inventories. Display aliases, retaining `server_id` for
37
- commands. Explain readiness separately from ownership.
36
+ errors are not empty inventories. Show each server's display name (`alias`) and
37
+ returned `description` to the user, retaining `server_id` for commands. If a
38
+ description is empty or absent, say "No description set." Explain readiness
39
+ separately from ownership.
38
40
 
39
41
  If discovery emits `server_metadata_prompt`, show its question and explain that
40
42
  the owner can always change the server's name and description later. Ask for the
@@ -55,10 +57,11 @@ deliver it when received. Never offer this edit for a shared server whose
55
57
  list does not prove an empty account. Ask the user to check their workspaces at
56
58
  https://teamclaws.com/app/; only if they confirm they have none, direct them to
57
59
  purchase there. Do not claim inventory support is deployed from local code alone.
58
- - With one pairing-ready workspace, name its alias and ask, “Would you like to
59
- pair with **[alias]** now?” Never automatically pair it.
60
- - With several, list aliases and ask, “Would you like to pair with any of these
61
- now?” If aliases repeat, distinguish them using their workspace addresses.
60
+ - With one pairing-ready workspace, show its alias and description and ask,
61
+ “Would you like to pair with **[alias]** now?” Never automatically pair it.
62
+ - With several, list each alias and description and ask, “Would you like to pair
63
+ with any of these now?” If aliases repeat, distinguish them using their
64
+ workspace addresses.
62
65
 
63
66
  For each selected workspace, follow [teamclaws-pair](../teamclaws-pair/SKILL.md)
64
67
  with its stable identifier. A prior explicit selection already authorizes that
@@ -28,7 +28,8 @@ explicit workspace address or server identifier is supplied. On `login_required`
28
28
  or `invalid_authentication` (exit 3), explain that login is required. For a
29
29
  status-only request, stop. For an authorized connect request, follow the login
30
30
  skill and resume after verified login. Do not retry with an explicit target to bypass login.
31
- The CLI verifies account authentication before workspace contact or approval.
31
+ The CLI requires its saved authorization and matching machine key. Existing
32
+ pairings without this binding require re-pairing.
32
33
 
33
34
  ## Status-only guardrail
34
35
 
@@ -55,9 +56,11 @@ Use `--no-open` only when automatic browser opening is undesirable. The
55
56
  `pairing_browser_required` event still provides the safe approval address.
56
57
 
57
58
  When no selected stable identifier is known, run `teamclaws servers list --json`,
58
- show aliases and readiness, and ask which workspace the user wants to pair—even
59
- when only one is available. Pass the selected `server_id`; aliases are display
60
- labels, not unique command targets. Targetless `teamclaws pair --json` returns
59
+ show each server's display name (`alias`), returned `description`, and readiness,
60
+ and ask which workspace the user wants to pair—even when only one is available.
61
+ If a description is empty or absent, say "No description set." Pass the selected
62
+ `server_id`; aliases are display labels, not unique command targets.
63
+ Targetless `teamclaws pair --json` returns
61
64
  `server_selection_required` for any nonempty pairing-ready list and never
62
65
  creates approval automatically. Interactive targetless pairing also requires a
63
66
  selection. An empty or not-ready list is not proof that the account owns no