teamclaws 1.0.4 → 2.0.1

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/dist/broker.js CHANGED
@@ -7,20 +7,23 @@ import { assertExactKeys, isObject, normalizeUuid, parsePendingPairing, parseWor
7
7
  export class BrokerClient {
8
8
  fetcher;
9
9
  deviceName;
10
+ authorization;
10
11
  constructor(options = {}) {
11
12
  this.fetcher = options.fetcher ?? fetch;
12
13
  this.deviceName = options.deviceName ?? hostname;
14
+ this.authorization = options.authorization;
13
15
  }
14
16
  currentDeviceName() {
15
17
  return normalizedDeviceName(this.deviceName());
16
18
  }
17
- async servers(accessToken, signal) {
18
- const response = await requestJson(this.fetcher, `${API_BASE}servers/?include_inventory=1&include_description=1&include_editable_metadata=1`, {
19
- headers: requestHeaders(accessToken),
19
+ async servers(signal) {
20
+ const url = `${API_BASE}servers/?include_inventory=1&include_description=1&include_editable_metadata=1`;
21
+ const response = await requestJson(this.fetcher, url, {
22
+ headers: await this.issuerHeaders("GET", url),
20
23
  ...(signal ? { signal } : {}),
21
24
  });
22
25
  if (response.status === 401) {
23
- throw new TeamClawsError("invalid_authentication", "The TeamClaws login is no longer valid. Run `teamclaws login` to authenticate.", {
26
+ throw new TeamClawsError("invalid_authentication", "The TeamClaws CLI authorization is inactive. Run `teamclaws login` to authorize this computer again.", {
24
27
  exitCode: 3,
25
28
  });
26
29
  }
@@ -29,7 +32,7 @@ export class BrokerClient {
29
32
  }
30
33
  return parseWorkspaceInventory(response.body);
31
34
  }
32
- async updateServerMetadata(accessToken, serverId, updates, signal) {
35
+ async updateServerMetadata(serverId, updates, signal) {
33
36
  if (Object.keys(updates).length === 0 ||
34
37
  (updates.alias !== undefined && [...updates.alias.trim()].length > 100) ||
35
38
  (updates.description !== undefined &&
@@ -37,9 +40,10 @@ export class BrokerClient {
37
40
  throw new TeamClawsError("invalid_server_metadata", "Provide a name of 100 characters or fewer and/or a description of 2000 characters or fewer.", { exitCode: 2 });
38
41
  }
39
42
  const id = normalizeUuid(serverId);
40
- const response = await requestJson(this.fetcher, `${API_BASE}servers/${id}/metadata/`, {
43
+ const url = `${API_BASE}servers/${id}/metadata/`;
44
+ const response = await requestJson(this.fetcher, url, {
41
45
  method: "POST",
42
- headers: requestHeaders(accessToken),
46
+ headers: await this.issuerHeaders("POST", url),
43
47
  body: updates,
44
48
  ...(signal ? { signal } : {}),
45
49
  });
@@ -50,11 +54,12 @@ export class BrokerClient {
50
54
  assertExactKeys(response.body, ["server"], [], "server metadata response");
51
55
  return editableServer(response.body.server, id);
52
56
  }
53
- async claimMetadataPrompt(accessToken, serverId, signal) {
57
+ async claimMetadataPrompt(serverId, signal) {
54
58
  const id = normalizeUuid(serverId);
55
- const response = await requestJson(this.fetcher, `${API_BASE}servers/${id}/metadata-prompt/claim/`, {
59
+ const url = `${API_BASE}servers/${id}/metadata-prompt/claim/`;
60
+ const response = await requestJson(this.fetcher, url, {
56
61
  method: "POST",
57
- headers: requestHeaders(accessToken),
62
+ headers: await this.issuerHeaders("POST", url),
58
63
  body: {},
59
64
  ...(signal ? { signal } : {}),
60
65
  });
@@ -70,11 +75,18 @@ export class BrokerClient {
70
75
  server: editableServer(response.body.server, id),
71
76
  };
72
77
  }
73
- async startPairing(target, accessToken, signal) {
74
- const request = pairingStartRequest(target, this.currentDeviceName());
75
- const response = await requestJson(this.fetcher, `${API_BASE}pairing/start/`, {
78
+ async startPairing(target, signal) {
79
+ if (!this.authorization)
80
+ throw new TeamClawsError("login_required", "CLI authorization is required for pairing.", { exitCode: 3 });
81
+ const grant = await this.authorization.requireGrant();
82
+ const request = pairingStartRequest(target, this.currentDeviceName(), grant);
83
+ const url = `${API_BASE}pairing/start/`;
84
+ const response = await requestJson(this.fetcher, url, {
76
85
  method: "POST",
77
- headers: requestHeaders(accessToken),
86
+ headers: {
87
+ ...requestHeaders(),
88
+ DPoP: await this.authorization.dpop(grant.machine_key, "POST", url),
89
+ },
78
90
  body: request,
79
91
  ...(signal ? { signal } : {}),
80
92
  });
@@ -91,6 +103,14 @@ export class BrokerClient {
91
103
  }
92
104
  return pending;
93
105
  }
106
+ async issuerHeaders(method, url) {
107
+ if (!this.authorization)
108
+ throw new TeamClawsError("login_required", "CLI authorization is required.", { exitCode: 3 });
109
+ return {
110
+ ...requestHeaders(),
111
+ ...(await this.authorization.accountHeadersFor(method, url)),
112
+ };
113
+ }
94
114
  }
95
115
  function invalidMetadataResponse() {
96
116
  return new TeamClawsError("invalid_response", "TeamClaws returned invalid server metadata.", { exitCode: 9 });
@@ -114,7 +134,7 @@ function editableServer(value, expectedId) {
114
134
  can_edit_metadata: true,
115
135
  };
116
136
  }
117
- export function pairingStartRequest(target, deviceName) {
137
+ export function pairingStartRequest(target, deviceName, grant) {
118
138
  return {
119
139
  contract_version: CONTRACT_VERSION,
120
140
  target,
@@ -122,6 +142,7 @@ export function pairingStartRequest(target, deviceName) {
122
142
  device_name: deviceName,
123
143
  client_name: CLIENT_NAME,
124
144
  client_version: PACKAGE_VERSION,
145
+ authorization_id: grant.authorization_id,
125
146
  };
126
147
  }
127
148
  export function normalizedDeviceName(value) {
@@ -131,11 +152,9 @@ export function normalizedDeviceName(value) {
131
152
  .slice(0, MAX_DEVICE_NAME_LENGTH);
132
153
  return normalized || "Unnamed computer";
133
154
  }
134
- function requestHeaders(accessToken) {
155
+ function requestHeaders() {
135
156
  const headers = {
136
157
  "User-Agent": `${CLIENT_NAME}/${PACKAGE_VERSION}`.slice(0, MAX_CLIENT_LABEL_LENGTH),
137
158
  };
138
- if (accessToken)
139
- headers.Authorization = `Bearer ${accessToken}`;
140
159
  return headers;
141
160
  }
package/dist/constants.js CHANGED
@@ -1,11 +1,12 @@
1
1
  export const PACKAGE_NAME = "teamclaws";
2
- export const PACKAGE_VERSION = "1.0.4";
2
+ export const PACKAGE_VERSION = "2.0.1";
3
3
  export const CLIENT_NAME = "teamclaws-cli";
4
4
  export const CONTRACT_VERSION = 1;
5
5
  export const OUTPUT_SCHEMA_VERSION = 1;
6
6
  export const TEAMCLAWS_ORIGIN = "https://www.teamclaws.com";
7
7
  export const API_RESOURCE = `${TEAMCLAWS_ORIGIN}/api/cli/v1`;
8
8
  export const API_BASE = `${API_RESOURCE}/`;
9
+ export const API_V2_BASE = `${TEAMCLAWS_ORIGIN}/api/cli/v2/`;
9
10
  export const OAUTH_CLIENT_ID = "teamclaws-cli";
10
11
  export const OAUTH_DEVICE_AUTHORIZATION_URL = `${TEAMCLAWS_ORIGIN}/o/device-authorization/`;
11
12
  export const OAUTH_TOKEN_URL = `${TEAMCLAWS_ORIGIN}/o/token/`;
@@ -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
+ }