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
package/dist/external-chat.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { PACKAGE_VERSION } from "./constants.js";
|
|
3
3
|
import { TeamClawsError } from "./errors.js";
|
|
4
|
-
import { errorFromResponse,
|
|
4
|
+
import { errorFromResponse, } from "./http.js";
|
|
5
|
+
import { TargetAuthorization, requestTargetJson, } from "./target-authorization.js";
|
|
5
6
|
import { isObject, normalizeUuid, parseVerifiedWorkspace, parseVerifiedDevice, requireHttpsUrl, requireInteger, requireString, } from "./validation.js";
|
|
6
7
|
const REQUIRED_SCOPES = new Set([
|
|
7
8
|
"external_chat:capabilities:read",
|
|
@@ -27,12 +28,16 @@ export class ExternalChatClient {
|
|
|
27
28
|
now;
|
|
28
29
|
store;
|
|
29
30
|
output;
|
|
31
|
+
authorization;
|
|
30
32
|
wait;
|
|
31
33
|
constructor(options) {
|
|
32
34
|
this.fetcher = options.fetcher ?? fetch;
|
|
33
35
|
this.now = options.now ?? (() => performance.now());
|
|
34
36
|
this.store = options.store;
|
|
35
37
|
this.output = options.output;
|
|
38
|
+
this.authorization =
|
|
39
|
+
options.authorization ??
|
|
40
|
+
new TargetAuthorization(this.store, this.fetcher);
|
|
36
41
|
this.wait = options.wait ?? waitFor;
|
|
37
42
|
}
|
|
38
43
|
async capabilities(target, signal) {
|
|
@@ -64,9 +69,9 @@ export class ExternalChatClient {
|
|
|
64
69
|
? AbortSignal.any([timeout, signal])
|
|
65
70
|
: timeout;
|
|
66
71
|
try {
|
|
67
|
-
const
|
|
72
|
+
const url = `${credential.origin}/teamclaws-admin/api/device-chat/v1/capabilities/`;
|
|
73
|
+
const response = await requestTargetJson(this.fetcher, this.authorization, url, credential, {
|
|
68
74
|
headers: {
|
|
69
|
-
Authorization: bearer(credential),
|
|
70
75
|
"User-Agent": `TeamClaws-CLI/${PACKAGE_VERSION}`,
|
|
71
76
|
},
|
|
72
77
|
timeoutMs,
|
|
@@ -124,7 +129,7 @@ export class ExternalChatClient {
|
|
|
124
129
|
const message = validateMessage(options.message, context.capabilities);
|
|
125
130
|
this.output.chatCapabilities(selection);
|
|
126
131
|
const clientRequestId = validUuid(options.clientRequestId ?? randomUUID());
|
|
127
|
-
const response = await this.request(`${context.credential.origin}/teamclaws-admin/api/device-chat/v1/sessions/`, context.
|
|
132
|
+
const response = await this.request(`${context.credential.origin}/teamclaws-admin/api/device-chat/v1/sessions/`, context.credential, {
|
|
128
133
|
method: "POST",
|
|
129
134
|
body: {
|
|
130
135
|
client_request_id: clientRequestId,
|
|
@@ -150,7 +155,7 @@ export class ExternalChatClient {
|
|
|
150
155
|
const sessionId = validUuid(options.sessionId);
|
|
151
156
|
const message = validateMessage(options.message, context.capabilities);
|
|
152
157
|
const clientRequestId = validUuid(options.clientRequestId ?? randomUUID());
|
|
153
|
-
const response = await this.request(`${context.credential.origin}/teamclaws-admin/api/device-chat/v1/sessions/${sessionId}/messages/create/`, context.
|
|
158
|
+
const response = await this.request(`${context.credential.origin}/teamclaws-admin/api/device-chat/v1/sessions/${sessionId}/messages/create/`, context.credential, {
|
|
154
159
|
method: "POST",
|
|
155
160
|
body: { client_request_id: clientRequestId, body_text: message },
|
|
156
161
|
}, [200, 202], signal);
|
|
@@ -171,7 +176,7 @@ export class ExternalChatClient {
|
|
|
171
176
|
const credential = await this.store.findWorkspace(target);
|
|
172
177
|
if (!credential)
|
|
173
178
|
throw new TeamClawsError("re_pair_required", "No saved pairing was found for this workspace. Pair it first.", { exitCode: 3 });
|
|
174
|
-
const response = await this.request(`${credential.origin}/teamclaws-admin/api/device-pairings/me/`,
|
|
179
|
+
const response = await this.request(`${credential.origin}/teamclaws-admin/api/device-pairings/me/`, credential, {}, [200], signal);
|
|
175
180
|
parseVerifiedDevice(response.body, credential.device_id);
|
|
176
181
|
requirePairingScopes(response.body);
|
|
177
182
|
this.output.chatSessionsList(credential, await this.store.listConversations(credential));
|
|
@@ -203,8 +208,7 @@ export class ExternalChatClient {
|
|
|
203
208
|
if (!credential) {
|
|
204
209
|
throw new TeamClawsError("re_pair_required", "No saved pairing was found for this workspace. Pair it first.", { exitCode: 3 });
|
|
205
210
|
}
|
|
206
|
-
const
|
|
207
|
-
const verifiedResponse = await this.request(`${credential.origin}/teamclaws-admin/api/device-pairings/me/`, authorization, {}, [200], signal);
|
|
211
|
+
const verifiedResponse = await this.request(`${credential.origin}/teamclaws-admin/api/device-pairings/me/`, credential, {}, [200], signal);
|
|
208
212
|
const verified = parseVerifiedWorkspace(verifiedResponse.body, credential.device_id, credential.origin);
|
|
209
213
|
requirePairingScopes(verifiedResponse.body);
|
|
210
214
|
const handoff = requireReadyHandoff(verified.external_chat_handoff);
|
|
@@ -212,14 +216,12 @@ export class ExternalChatClient {
|
|
|
212
216
|
const result = await this.listInventory(target, signal, credential);
|
|
213
217
|
return {
|
|
214
218
|
credential,
|
|
215
|
-
authorization,
|
|
216
219
|
capabilities: parseCapabilities(result.external_chat_capabilities, true),
|
|
217
220
|
};
|
|
218
221
|
}
|
|
219
|
-
const capabilityResponse = await this.request(new URL(handoff.capabilities_url, `${credential.origin}/`).toString(),
|
|
222
|
+
const capabilityResponse = await this.request(new URL(handoff.capabilities_url, `${credential.origin}/`).toString(), credential, {}, [200], signal);
|
|
220
223
|
return {
|
|
221
224
|
credential,
|
|
222
|
-
authorization,
|
|
223
225
|
capabilities: parseCapabilities(capabilityResponse.body),
|
|
224
226
|
};
|
|
225
227
|
}
|
|
@@ -237,7 +239,7 @@ export class ExternalChatClient {
|
|
|
237
239
|
known_revision: String(knownRevision),
|
|
238
240
|
after_response_message_id: String(responseCursor),
|
|
239
241
|
});
|
|
240
|
-
const response = await this.request(`${context.credential.origin}/teamclaws-admin/api/device-chat/v1/requests/${current.requestId}/?${query.toString()}`, context.
|
|
242
|
+
const response = await this.request(`${context.credential.origin}/teamclaws-admin/api/device-chat/v1/requests/${current.requestId}/?${query.toString()}`, context.credential, {}, [200], signal);
|
|
241
243
|
if (!isObject(response.body))
|
|
242
244
|
throw invalidChatResponse();
|
|
243
245
|
state = parseRequest(response.body.request, context.credential.origin);
|
|
@@ -291,21 +293,22 @@ export class ExternalChatClient {
|
|
|
291
293
|
this.output.chatRequestFailed(state.sessionId, state.requestId, state.status, code, detail, state.ownerUrl);
|
|
292
294
|
return 1;
|
|
293
295
|
}
|
|
294
|
-
async request(url,
|
|
296
|
+
async request(url, credential, options, expected, signal) {
|
|
295
297
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
296
298
|
try {
|
|
297
|
-
const response = await
|
|
299
|
+
const response = await requestTargetJson(this.fetcher, this.authorization, url, credential, {
|
|
298
300
|
...options,
|
|
299
301
|
headers: {
|
|
300
302
|
...options.headers,
|
|
301
|
-
Authorization: authorization,
|
|
302
303
|
"User-Agent": `TeamClaws-CLI/${PACKAGE_VERSION}`,
|
|
303
304
|
},
|
|
304
305
|
...(signal ? { signal } : {}),
|
|
305
306
|
});
|
|
306
307
|
if (expected.includes(response.status))
|
|
307
308
|
return response;
|
|
308
|
-
if (
|
|
309
|
+
if (options.method !== "POST" &&
|
|
310
|
+
response.status === 429 &&
|
|
311
|
+
attempt < 2) {
|
|
309
312
|
await this.wait(retryAfter(response) * 1_000, signal);
|
|
310
313
|
continue;
|
|
311
314
|
}
|
|
@@ -314,6 +317,7 @@ export class ExternalChatClient {
|
|
|
314
317
|
catch (error) {
|
|
315
318
|
if (error instanceof TeamClawsError &&
|
|
316
319
|
error.retryable &&
|
|
320
|
+
options.method !== "POST" &&
|
|
317
321
|
attempt < 2 &&
|
|
318
322
|
!signal?.aborted) {
|
|
319
323
|
await this.wait((attempt + 1) * 1_000, signal);
|
|
@@ -325,9 +329,6 @@ export class ExternalChatClient {
|
|
|
325
329
|
throw new TeamClawsError("external_chat_failed", "The workspace did not complete the external chat request.", { exitCode: 5 });
|
|
326
330
|
}
|
|
327
331
|
}
|
|
328
|
-
function bearer(credential) {
|
|
329
|
-
return `Bearer ${credential.device_id}:${credential.device_token}`;
|
|
330
|
-
}
|
|
331
332
|
function requireReadyHandoff(handoff) {
|
|
332
333
|
if (handoff.status === "ready")
|
|
333
334
|
return handoff;
|
package/dist/http.js
CHANGED
|
@@ -61,6 +61,8 @@ export async function requestJson(fetcher, url, options = {}) {
|
|
|
61
61
|
.get("content-type")
|
|
62
62
|
?.split(";", 1)[0]
|
|
63
63
|
?.trim();
|
|
64
|
+
if (response.status === 204)
|
|
65
|
+
return { status: 204, headers: response.headers, body: null };
|
|
64
66
|
if (contentType !== "application/json") {
|
|
65
67
|
throw new TeamClawsError("invalid_response", "TeamClaws returned a non-JSON response.", {
|
|
66
68
|
exitCode: 9,
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign as signBytes, } from "node:crypto";
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, link, unlink } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { TeamClawsError } from "./errors.js";
|
|
9
|
+
const execFile = promisify(execFileCallback);
|
|
10
|
+
export class OsMachineKeyProvider {
|
|
11
|
+
root;
|
|
12
|
+
constructor(root) {
|
|
13
|
+
this.root = root ?? join(homedir(), ".teamclaws");
|
|
14
|
+
}
|
|
15
|
+
async create() {
|
|
16
|
+
if (process.platform === "linux" || process.platform === "darwin")
|
|
17
|
+
return this.createPosix();
|
|
18
|
+
if (process.platform !== "win32")
|
|
19
|
+
throw keyUnavailable();
|
|
20
|
+
const name = `teamclaws-cli-${randomUUID()}`;
|
|
21
|
+
const output = await runCng("create", name);
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(output);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw keyUnavailable();
|
|
28
|
+
}
|
|
29
|
+
if (typeof parsed !== "object" ||
|
|
30
|
+
parsed === null ||
|
|
31
|
+
!("x" in parsed) ||
|
|
32
|
+
!("y" in parsed) ||
|
|
33
|
+
typeof parsed.x !== "string" ||
|
|
34
|
+
typeof parsed.y !== "string" ||
|
|
35
|
+
!/^[A-Za-z0-9_-]{43}$/u.test(parsed.x) ||
|
|
36
|
+
!/^[A-Za-z0-9_-]{43}$/u.test(parsed.y))
|
|
37
|
+
throw keyUnavailable();
|
|
38
|
+
const public_jwk = {
|
|
39
|
+
kty: "EC",
|
|
40
|
+
crv: "P-256",
|
|
41
|
+
x: parsed.x,
|
|
42
|
+
y: parsed.y,
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
provider: "windows-cng",
|
|
46
|
+
name,
|
|
47
|
+
public_jwk,
|
|
48
|
+
jkt: jwkThumbprint(public_jwk),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async sign(reference, input) {
|
|
52
|
+
if ((process.platform === "linux" || process.platform === "darwin") &&
|
|
53
|
+
reference.provider === "posix-file")
|
|
54
|
+
return this.signPosix(reference, input);
|
|
55
|
+
if (process.platform !== "win32" || reference.provider !== "windows-cng")
|
|
56
|
+
throw keyUnavailable();
|
|
57
|
+
if (!/^teamclaws-cli-[0-9a-f-]{36}$/u.test(reference.name))
|
|
58
|
+
throw keyUnavailable();
|
|
59
|
+
const signature = (await runCng("sign", reference.name, input)).trim();
|
|
60
|
+
if (!/^[A-Za-z0-9_-]{86}$/u.test(signature))
|
|
61
|
+
throw keyUnavailable();
|
|
62
|
+
return signature;
|
|
63
|
+
}
|
|
64
|
+
keyDirectory() {
|
|
65
|
+
return join(this.root, "machine-keys");
|
|
66
|
+
}
|
|
67
|
+
async privatePosixDirectory() {
|
|
68
|
+
const directory = this.keyDirectory();
|
|
69
|
+
try {
|
|
70
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
71
|
+
await assertPrivateDirectory(this.root);
|
|
72
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
73
|
+
await assertPrivateDirectory(directory);
|
|
74
|
+
return directory;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw keyUnavailable();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async createPosix() {
|
|
81
|
+
const directory = await this.privatePosixDirectory();
|
|
82
|
+
const name = `teamclaws-cli-${randomUUID()}`;
|
|
83
|
+
const temporary = join(directory, `.${name}.tmp`);
|
|
84
|
+
const destination = join(directory, `${name}.pem`);
|
|
85
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", {
|
|
86
|
+
namedCurve: "prime256v1",
|
|
87
|
+
});
|
|
88
|
+
const public_jwk = publicKey.export({ format: "jwk" });
|
|
89
|
+
const pem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
90
|
+
let handle;
|
|
91
|
+
let linked = false;
|
|
92
|
+
try {
|
|
93
|
+
handle = await open(temporary, fsConstants.O_CREAT |
|
|
94
|
+
fsConstants.O_EXCL |
|
|
95
|
+
fsConstants.O_WRONLY |
|
|
96
|
+
fsConstants.O_NOFOLLOW, 0o600);
|
|
97
|
+
await handle.writeFile(pem);
|
|
98
|
+
await handle.sync();
|
|
99
|
+
await handle.close();
|
|
100
|
+
handle = undefined;
|
|
101
|
+
await assertPrivateFile(temporary);
|
|
102
|
+
await link(temporary, destination);
|
|
103
|
+
linked = true;
|
|
104
|
+
await unlink(temporary);
|
|
105
|
+
const directoryHandle = await open(directory, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
106
|
+
try {
|
|
107
|
+
await directoryHandle.sync();
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await directoryHandle.close();
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
provider: "posix-file",
|
|
114
|
+
name,
|
|
115
|
+
public_jwk,
|
|
116
|
+
jkt: jwkThumbprint(public_jwk),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
await handle?.close().catch(() => undefined);
|
|
121
|
+
await unlink(temporary).catch(() => undefined);
|
|
122
|
+
if (linked)
|
|
123
|
+
await unlink(destination).catch(() => undefined);
|
|
124
|
+
throw keyUnavailable();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async signPosix(reference, input) {
|
|
128
|
+
if (!/^teamclaws-cli-[0-9a-f-]{36}$/u.test(reference.name))
|
|
129
|
+
throw keyUnavailable();
|
|
130
|
+
try {
|
|
131
|
+
await assertPrivateDirectory(this.root);
|
|
132
|
+
await assertPrivateDirectory(this.keyDirectory());
|
|
133
|
+
const path = join(this.keyDirectory(), `${reference.name}.pem`);
|
|
134
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
135
|
+
let pem;
|
|
136
|
+
try {
|
|
137
|
+
const info = await handle.stat();
|
|
138
|
+
if (!info.isFile() ||
|
|
139
|
+
info.uid !== process.getuid?.() ||
|
|
140
|
+
(info.mode & 0o777) !== 0o600 ||
|
|
141
|
+
info.size > 8192)
|
|
142
|
+
throw keyUnavailable();
|
|
143
|
+
pem = await handle.readFile();
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await handle.close();
|
|
147
|
+
}
|
|
148
|
+
const privateKey = createPrivateKey(pem);
|
|
149
|
+
const actualJwk = createPublicKey(privateKey).export({
|
|
150
|
+
format: "jwk",
|
|
151
|
+
});
|
|
152
|
+
if (jwkThumbprint(actualJwk) !== reference.jkt ||
|
|
153
|
+
actualJwk.x !== reference.public_jwk.x ||
|
|
154
|
+
actualJwk.y !== reference.public_jwk.y)
|
|
155
|
+
throw keyUnavailable();
|
|
156
|
+
return signBytes("sha256", Buffer.from(input, "ascii"), {
|
|
157
|
+
key: privateKey,
|
|
158
|
+
dsaEncoding: "ieee-p1363",
|
|
159
|
+
}).toString("base64url");
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw keyUnavailable();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function assertPrivateDirectory(path) {
|
|
167
|
+
const info = await lstat(path);
|
|
168
|
+
if (!info.isDirectory() ||
|
|
169
|
+
info.isSymbolicLink() ||
|
|
170
|
+
info.uid !== process.getuid?.() ||
|
|
171
|
+
(info.mode & 0o777) !== 0o700)
|
|
172
|
+
throw keyUnavailable();
|
|
173
|
+
}
|
|
174
|
+
async function assertPrivateFile(path) {
|
|
175
|
+
const info = await lstat(path);
|
|
176
|
+
if (!info.isFile() ||
|
|
177
|
+
info.isSymbolicLink() ||
|
|
178
|
+
info.uid !== process.getuid?.() ||
|
|
179
|
+
(info.mode & 0o777) !== 0o600)
|
|
180
|
+
throw keyUnavailable();
|
|
181
|
+
}
|
|
182
|
+
export function jwkThumbprint(jwk) {
|
|
183
|
+
return createHash("sha256")
|
|
184
|
+
.update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }), "ascii")
|
|
185
|
+
.digest("base64url");
|
|
186
|
+
}
|
|
187
|
+
// The named CNG key is scoped to the Windows user and is never exportable.
|
|
188
|
+
// Only public coordinates and signatures leave CNG. No private JWK is serialized.
|
|
189
|
+
const cngScript = String.raw `
|
|
190
|
+
$ErrorActionPreference = 'Stop'
|
|
191
|
+
$provider = [System.Security.Cryptography.CngProvider]::MicrosoftSoftwareKeyStorageProvider
|
|
192
|
+
if ($operation -eq 'create') {
|
|
193
|
+
$parameters = New-Object System.Security.Cryptography.CngKeyCreationParameters
|
|
194
|
+
$parameters.Provider = $provider
|
|
195
|
+
$parameters.KeyUsage = [System.Security.Cryptography.CngKeyUsages]::Signing
|
|
196
|
+
$parameters.ExportPolicy = [System.Security.Cryptography.CngExportPolicies]::None
|
|
197
|
+
$key = [System.Security.Cryptography.CngKey]::Create([System.Security.Cryptography.CngAlgorithm]::ECDsaP256, $keyName, $parameters)
|
|
198
|
+
try {
|
|
199
|
+
$ecdsa = New-Object System.Security.Cryptography.ECDsaCng($key)
|
|
200
|
+
try {
|
|
201
|
+
$point = $ecdsa.ExportParameters($false).Q
|
|
202
|
+
$x = [Convert]::ToBase64String($point.X).TrimEnd('=').Replace('+','-').Replace('/','_')
|
|
203
|
+
$y = [Convert]::ToBase64String($point.Y).TrimEnd('=').Replace('+','-').Replace('/','_')
|
|
204
|
+
Write-Output ('{"x":"' + $x + '","y":"' + $y + '"}')
|
|
205
|
+
} finally { $ecdsa.Dispose() }
|
|
206
|
+
} finally { $key.Dispose() }
|
|
207
|
+
} elseif ($operation -eq 'sign') {
|
|
208
|
+
$key = [System.Security.Cryptography.CngKey]::Open($keyName, $provider)
|
|
209
|
+
try {
|
|
210
|
+
$ecdsa = New-Object System.Security.Cryptography.ECDsaCng($key)
|
|
211
|
+
try {
|
|
212
|
+
$hash = [System.Security.Cryptography.SHA256]::Create()
|
|
213
|
+
try { $digest = $hash.ComputeHash([Text.Encoding]::ASCII.GetBytes($signingInput)) }
|
|
214
|
+
finally { $hash.Dispose() }
|
|
215
|
+
$signature = $ecdsa.SignHash($digest)
|
|
216
|
+
Write-Output ([Convert]::ToBase64String($signature).TrimEnd('=').Replace('+','-').Replace('/','_'))
|
|
217
|
+
} finally { $ecdsa.Dispose() }
|
|
218
|
+
} finally { $key.Dispose() }
|
|
219
|
+
} else { throw 'Unsupported machine key operation' }
|
|
220
|
+
`;
|
|
221
|
+
async function runCng(operation, name, input) {
|
|
222
|
+
try {
|
|
223
|
+
if (!/^(create|sign)$/u.test(operation) ||
|
|
224
|
+
!/^teamclaws-cli-[0-9a-f-]{36}$/u.test(name) ||
|
|
225
|
+
(input !== undefined && !/^[A-Za-z0-9_.-]{1,4096}$/u.test(input)))
|
|
226
|
+
throw keyUnavailable();
|
|
227
|
+
const script = `$operation='${operation}'\n$keyName='${name}'\n$signingInput='${input ?? ""}'\n${cngScript}`;
|
|
228
|
+
const { stdout } = await execFile("powershell.exe", [
|
|
229
|
+
"-NoProfile",
|
|
230
|
+
"-NonInteractive",
|
|
231
|
+
"-EncodedCommand",
|
|
232
|
+
ConvertScript(script),
|
|
233
|
+
], { encoding: "utf8", windowsHide: true, maxBuffer: 4096 });
|
|
234
|
+
return stdout.trim();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw keyUnavailable();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function ConvertScript(script) {
|
|
241
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
242
|
+
}
|
|
243
|
+
function keyUnavailable() {
|
|
244
|
+
return new TeamClawsError("machine_key_unavailable", "A protected machine key is required for CLI authorization.", { exitCode: 9 });
|
|
245
|
+
}
|
package/dist/pairing.js
CHANGED
|
@@ -4,6 +4,7 @@ import { PAIRING_STATUS_TIMEOUT_MS } from "./constants.js";
|
|
|
4
4
|
import { errorFromResponse, requestJson, } from "./http.js";
|
|
5
5
|
import { directTargetUrl, isObject, parsePairingCredential, parseVerifiedDevice, parseVerifiedWorkspace, requireInteger, } from "./validation.js";
|
|
6
6
|
import { openExternalBrowser } from "./browser.js";
|
|
7
|
+
import { TargetAuthorization, requestTargetJson, } from "./target-authorization.js";
|
|
7
8
|
export class PairingClient {
|
|
8
9
|
fetcher;
|
|
9
10
|
store;
|
|
@@ -11,6 +12,7 @@ export class PairingClient {
|
|
|
11
12
|
openBrowser;
|
|
12
13
|
now;
|
|
13
14
|
wait;
|
|
15
|
+
authorization;
|
|
14
16
|
constructor(options) {
|
|
15
17
|
this.fetcher = options.fetcher ?? fetch;
|
|
16
18
|
this.store = options.store;
|
|
@@ -18,13 +20,18 @@ export class PairingClient {
|
|
|
18
20
|
this.openBrowser = options.openBrowser ?? openExternalBrowser;
|
|
19
21
|
this.now = options.now ?? Date.now;
|
|
20
22
|
this.wait = options.wait ?? waitFor;
|
|
23
|
+
this.authorization =
|
|
24
|
+
options.authorization ??
|
|
25
|
+
new TargetAuthorization(this.store, this.fetcher);
|
|
21
26
|
}
|
|
22
27
|
async status(target, signal) {
|
|
23
28
|
let credential;
|
|
24
29
|
try {
|
|
25
30
|
credential = await this.store.findWorkspace(target);
|
|
26
31
|
}
|
|
27
|
-
catch {
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof TeamClawsError && error.code === "re_pair_required")
|
|
34
|
+
throw error;
|
|
28
35
|
this.output.pairingStatus("unreachable");
|
|
29
36
|
return "unreachable";
|
|
30
37
|
}
|
|
@@ -38,10 +45,8 @@ export class PairingClient {
|
|
|
38
45
|
display_name: credential.display_name,
|
|
39
46
|
};
|
|
40
47
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
Authorization: `Bearer ${credential.device_id}:${credential.device_token}`,
|
|
44
|
-
},
|
|
48
|
+
const url = `${credential.origin}/teamclaws-admin/api/device-pairings/me/`;
|
|
49
|
+
const response = await requestTargetJson(this.fetcher, this.authorization, url, credential, {
|
|
45
50
|
timeoutMs: PAIRING_STATUS_TIMEOUT_MS,
|
|
46
51
|
...(signal ? { signal } : {}),
|
|
47
52
|
});
|
|
@@ -63,6 +68,9 @@ export class PairingClient {
|
|
|
63
68
|
catch (error) {
|
|
64
69
|
if (signal?.aborted)
|
|
65
70
|
throw error;
|
|
71
|
+
if (error instanceof TeamClawsError &&
|
|
72
|
+
!["network_error", "network_timeout"].includes(error.code))
|
|
73
|
+
throw error;
|
|
66
74
|
this.output.pairingStatus("unreachable", workspace);
|
|
67
75
|
return "unreachable";
|
|
68
76
|
}
|
|
@@ -74,50 +82,52 @@ export class PairingClient {
|
|
|
74
82
|
let credential;
|
|
75
83
|
try {
|
|
76
84
|
credential = await this.poll(pending, signal);
|
|
85
|
+
const grant = await this.authorization.requireGrant();
|
|
86
|
+
if (credential.pairing_id !== credential.device_id ||
|
|
87
|
+
credential.server_id !== pending.server_id ||
|
|
88
|
+
credential.origin !== pending.target.origin ||
|
|
89
|
+
credential.requester_sub !== grant.sub ||
|
|
90
|
+
credential.authorization_id !== grant.authorization_id ||
|
|
91
|
+
credential.machine_jkt !== grant.machine_key.jkt)
|
|
92
|
+
throw verificationError();
|
|
77
93
|
const initial = {
|
|
78
|
-
version:
|
|
94
|
+
version: 2,
|
|
79
95
|
server_id: pending.server_id,
|
|
80
96
|
origin: pending.target.origin,
|
|
81
97
|
display_name: deviceName,
|
|
82
98
|
device_id: credential.device_id,
|
|
99
|
+
pairing_id: credential.pairing_id,
|
|
83
100
|
device_token: credential.device_token,
|
|
101
|
+
requester_sub: credential.requester_sub,
|
|
102
|
+
authorization_id: credential.authorization_id,
|
|
103
|
+
machine_jkt: credential.machine_jkt,
|
|
84
104
|
created_at: new Date(this.now()).toISOString(),
|
|
85
105
|
};
|
|
86
|
-
await this.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (typeof timestamp === "string" &&
|
|
105
|
-
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|\+00:00)$/.test(timestamp) &&
|
|
106
|
-
Number.isFinite(Date.parse(timestamp))) {
|
|
107
|
-
snapshot = parseCapabilityInventory(credential.external_chat_capabilities);
|
|
108
|
-
generatedAt = timestamp;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
// Optional inventory must not invalidate a successfully verified credential.
|
|
106
|
+
const verification = await this.verify(pending, initial, signal);
|
|
107
|
+
const verified = {
|
|
108
|
+
...initial,
|
|
109
|
+
display_name: verification.display_name,
|
|
110
|
+
};
|
|
111
|
+
await this.store.writeWorkspace(verified);
|
|
112
|
+
let snapshot;
|
|
113
|
+
let generatedAt;
|
|
114
|
+
if (Object.hasOwn(credential, "external_chat_capabilities")) {
|
|
115
|
+
snapshot = null;
|
|
116
|
+
generatedAt = null;
|
|
117
|
+
try {
|
|
118
|
+
const timestamp = credential.external_chat_capabilities_generated_at;
|
|
119
|
+
if (typeof timestamp === "string" &&
|
|
120
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|\+00:00)$/.test(timestamp) &&
|
|
121
|
+
Number.isFinite(Date.parse(timestamp))) {
|
|
122
|
+
snapshot = parseCapabilityInventory(credential.external_chat_capabilities);
|
|
123
|
+
generatedAt = timestamp;
|
|
113
124
|
}
|
|
114
125
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await this.store.removeWorkspace(pending.server_id);
|
|
119
|
-
throw error;
|
|
126
|
+
catch {
|
|
127
|
+
// Optional inventory must not invalidate a successfully verified credential.
|
|
128
|
+
}
|
|
120
129
|
}
|
|
130
|
+
this.output.pairingComplete(pending.server_id, pending.target.origin, verification.display_name, verification.external_chat_handoff, snapshot, generatedAt);
|
|
121
131
|
}
|
|
122
132
|
catch (error) {
|
|
123
133
|
if (signal?.aborted) {
|
|
@@ -173,10 +183,8 @@ export class PairingClient {
|
|
|
173
183
|
throw errorFromResponse(response, "cancel_failed");
|
|
174
184
|
}
|
|
175
185
|
async verify(pending, credential, signal) {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
Authorization: `Bearer ${credential.device_id}:${credential.device_token}`,
|
|
179
|
-
},
|
|
186
|
+
const url = directTargetUrl(pending.target, pending.target.credential_verification_path);
|
|
187
|
+
const response = await requestTargetJson(this.fetcher, this.authorization, url, credential, {
|
|
180
188
|
...(signal ? { signal } : {}),
|
|
181
189
|
});
|
|
182
190
|
if (response.status < 200 || response.status >= 300)
|
package/dist/shell.js
CHANGED
|
@@ -2,18 +2,17 @@ import { request as httpRequest } from "node:http";
|
|
|
2
2
|
import { request as httpsRequest } from "node:https";
|
|
3
3
|
import { Readable } from "node:stream";
|
|
4
4
|
import { TeamClawsError } from "./errors.js";
|
|
5
|
-
import { errorFromResponse
|
|
5
|
+
import { errorFromResponse } from "./http.js";
|
|
6
|
+
import { requestTargetJson } from "./target-authorization.js";
|
|
6
7
|
import { isObject } from "./validation.js";
|
|
7
|
-
export async function runShell(store, fetcher, target, command, signal) {
|
|
8
|
+
export async function runShell(store, fetcher, target, command, authorization, signal) {
|
|
8
9
|
const credential = await store.findWorkspace(target);
|
|
9
10
|
if (!credential) {
|
|
10
11
|
throw new TeamClawsError("re_pair_required", "No saved pairing was found for this workspace. Pair it first.", { exitCode: 3 });
|
|
11
12
|
}
|
|
12
|
-
const
|
|
13
|
+
const url = `${credential.origin}/teamclaws-admin/api/shell/`;
|
|
14
|
+
const response = await requestTargetJson(fetcher, authorization, url, credential, {
|
|
13
15
|
method: "POST",
|
|
14
|
-
headers: {
|
|
15
|
-
Authorization: `Bearer ${credential.device_id}:${credential.device_token}`,
|
|
16
|
-
},
|
|
17
16
|
textBody: command,
|
|
18
17
|
timeoutMs: null,
|
|
19
18
|
responseLimitBytes: null,
|
package/dist/storage.js
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { TeamClawsError } from "./errors.js";
|
|
8
|
+
import { parseDurableAuthorization, } from "./target-authorization.js";
|
|
8
9
|
import { isObject, requireHttpsUrl, requireInteger, normalizeUuid, parseLoginCredential, parseStatusWorkspaceCredential, parseWorkspaceCredential, } from "./validation.js";
|
|
9
10
|
const execFile = promisify(execFileCallback);
|
|
10
11
|
const SYSTEM_SID = "S-1-5-18";
|
|
@@ -13,11 +14,13 @@ const UUID_CREDENTIAL_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0
|
|
|
13
14
|
export class CredentialStore {
|
|
14
15
|
root;
|
|
15
16
|
loginPath;
|
|
17
|
+
authorizationPath;
|
|
16
18
|
workspaceDirectory;
|
|
17
19
|
platform;
|
|
18
20
|
constructor(options = {}) {
|
|
19
21
|
this.root = options.root ?? defaultRoot();
|
|
20
22
|
this.loginPath = join(this.root, "cli-auth.json");
|
|
23
|
+
this.authorizationPath = join(this.root, "cli-authorization.json");
|
|
21
24
|
this.workspaceDirectory = join(this.root, "clawgate-devices");
|
|
22
25
|
this.platform = options.platform ?? process.platform;
|
|
23
26
|
}
|
|
@@ -34,6 +37,19 @@ export class CredentialStore {
|
|
|
34
37
|
throw storageError();
|
|
35
38
|
});
|
|
36
39
|
}
|
|
40
|
+
async readAuthorization() {
|
|
41
|
+
const value = await this.readJsonIfPresent(this.authorizationPath);
|
|
42
|
+
return value === undefined ? undefined : parseDurableAuthorization(value);
|
|
43
|
+
}
|
|
44
|
+
async writeAuthorization(grant) {
|
|
45
|
+
await this.writePrivateJson(this.authorizationPath, parseDurableAuthorization(grant));
|
|
46
|
+
}
|
|
47
|
+
async removeAuthorization() {
|
|
48
|
+
await unlink(this.authorizationPath).catch((error) => {
|
|
49
|
+
if (error.code !== "ENOENT")
|
|
50
|
+
throw storageError();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
37
53
|
workspacePath(serverId) {
|
|
38
54
|
return join(this.workspaceDirectory, `${normalizeUuid(serverId)}.json`);
|
|
39
55
|
}
|