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.
- package/README.md +20 -12
- package/dist/app.js +23 -19
- package/dist/auth.js +74 -109
- package/dist/broker.js +37 -18
- package/dist/constants.js +2 -1
- package/dist/external-chat.js +20 -19
- package/dist/http.js +2 -0
- package/dist/machine-key.js +245 -0
- package/dist/pairing.js +50 -42
- package/dist/shell.js +5 -6
- package/dist/storage.js +16 -0
- package/dist/target-authorization.js +150 -0
- package/dist/validation.js +39 -7
- package/package.json +1 -1
- package/skills/teamclaws-login/SKILL.md +9 -6
- package/skills/teamclaws-onboard/SKILL.md +9 -6
- package/skills/teamclaws-pair/SKILL.md +7 -4
|
@@ -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
|
+
}
|
package/dist/validation.js
CHANGED
|
@@ -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", "
|
|
321
|
-
if (value.
|
|
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
|
-
|
|
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 !==
|
|
429
|
+
if (value.version !== 2)
|
|
418
430
|
throw invalidCredentialFile();
|
|
419
431
|
return {
|
|
420
|
-
version:
|
|
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)
|
|
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:
|
|
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
|
@@ -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
|
|
29
|
-
protected
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
37
|
-
|
|
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,
|
|
59
|
-
pair with **[alias]** now?” Never automatically pair it.
|
|
60
|
-
- With several, list
|
|
61
|
-
now?” If aliases repeat, distinguish them using their
|
|
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
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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
|