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.
@@ -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, requestJson, } from "./http.js";
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 response = await requestJson(this.fetcher, `${credential.origin}/teamclaws-admin/api/device-chat/v1/capabilities/`, {
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.authorization, {
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.authorization, {
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/`, bearer(credential), {}, [200], signal);
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 authorization = bearer(credential);
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(), authorization, {}, [200], signal);
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.authorization, {}, [200], signal);
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, authorization, options, expected, signal) {
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 requestJson(this.fetcher, url, {
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 (response.status === 429 && attempt < 2) {
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 response = await requestJson(this.fetcher, `${credential.origin}/teamclaws-admin/api/device-pairings/me/`, {
42
- headers: {
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: 1,
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.store.writeWorkspace(initial);
87
- try {
88
- const stored = await this.store.readWorkspace(pending.server_id);
89
- if (!stored)
90
- throw verificationError();
91
- const verification = await this.verify(pending, stored, signal);
92
- const verified = {
93
- ...stored,
94
- display_name: verification.display_name,
95
- };
96
- await this.store.writeWorkspace(verified);
97
- let snapshot;
98
- let generatedAt;
99
- if (Object.hasOwn(credential, "external_chat_capabilities")) {
100
- snapshot = null;
101
- generatedAt = null;
102
- try {
103
- const timestamp = credential.external_chat_capabilities_generated_at;
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
- this.output.pairingComplete(pending.server_id, pending.target.origin, verification.display_name, verification.external_chat_handoff, snapshot, generatedAt);
116
- }
117
- catch (error) {
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 response = await requestJson(this.fetcher, directTargetUrl(pending.target, pending.target.credential_verification_path), {
177
- headers: {
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, requestJson } from "./http.js";
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 response = await requestJson(fetcher, `${credential.origin}/teamclaws-admin/api/shell/`, {
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
  }