teamclaws 1.0.4 → 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-pair/SKILL.md +2 -1
|
@@ -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.
|
|
@@ -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
|
|