zas-agent 0.1.1 → 0.2.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/CHANGELOG.md +25 -1
- package/README.md +33 -22
- package/dist/cli.js +350 -70
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-09-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The `claude mcp add` and `codex mcp add` lines the terminal prints after
|
|
15
|
+
pairing quote the `--` separator. In PowerShell the npm-installed `claude`
|
|
16
|
+
and `codex` commands are script shims, and PowerShell kept a bare `--` for
|
|
17
|
+
itself, so `claude mcp add` refused `-y` as an unknown option.
|
|
18
|
+
|
|
19
|
+
## [0.2.0] - 2026-09-03
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Pairing no longer prints a code to type. The terminal listens on
|
|
24
|
+
`127.0.0.1`, the approval page hands it a one-time claim code, and the
|
|
25
|
+
agent is created only when the terminal claims. A page that cannot reach
|
|
26
|
+
the terminal shows the code, and the terminal asks for it. `pair` opens
|
|
27
|
+
the browser; `--no-open` and `ZAS_NO_OPEN=1` keep it closed.
|
|
28
|
+
- `zas_pair` takes an optional `code` for the case where the page shows one.
|
|
29
|
+
- Older servers are not supported by this release: the claim route is
|
|
30
|
+
required. Older CLIs keep working against the server.
|
|
31
|
+
|
|
10
32
|
## [0.1.1] - 2026-09-02
|
|
11
33
|
|
|
12
34
|
### Changed
|
|
@@ -36,6 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
36
58
|
two separate identities that cannot read each other's keys.
|
|
37
59
|
- Install snippets for Claude Code and Codex, printed by `zas-agent pair`.
|
|
38
60
|
|
|
39
|
-
[Unreleased]: https://github.com/soke1556/zas-agent/compare/v0.
|
|
61
|
+
[Unreleased]: https://github.com/soke1556/zas-agent/compare/v0.2.1...HEAD
|
|
62
|
+
[0.2.1]: https://github.com/soke1556/zas-agent/compare/v0.2.0...v0.2.1
|
|
63
|
+
[0.2.0]: https://github.com/soke1556/zas-agent/compare/v0.1.1...v0.2.0
|
|
40
64
|
[0.1.1]: https://github.com/soke1556/zas-agent/compare/v0.1.0...v0.1.1
|
|
41
65
|
[0.1.0]: https://github.com/soke1556/zas-agent/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -46,13 +46,13 @@ npx -y zas-agent pair --profile claude-code
|
|
|
46
46
|
**Claude Code**
|
|
47
47
|
|
|
48
48
|
```
|
|
49
|
-
claude mcp add zas -- npx -y zas-agent --profile claude-code
|
|
49
|
+
claude mcp add zas "--" npx -y zas-agent --profile claude-code
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
**Codex**
|
|
53
53
|
|
|
54
54
|
```
|
|
55
|
-
codex mcp add zas -- npx -y zas-agent --profile codex
|
|
55
|
+
codex mcp add zas "--" npx -y zas-agent --profile codex
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
Codex by hand, if you would rather edit `~/.codex/config.toml`:
|
|
@@ -63,28 +63,37 @@ command = "npx"
|
|
|
63
63
|
args = ["-y", "zas-agent", "--profile", "codex"]
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
### What pairing
|
|
66
|
+
### What pairing does
|
|
67
67
|
|
|
68
|
-
`zas-agent pair` mints the key pair, registers the public halves,
|
|
69
|
-
terminal shows
|
|
68
|
+
`zas-agent pair` mints the key pair, registers the public halves, opens the
|
|
69
|
+
approval page in your browser, and waits. The terminal shows:
|
|
70
70
|
|
|
71
71
|
```
|
|
72
72
|
Open this page signed in to your Zas account:
|
|
73
|
-
https://zas.red/agents/pair?p
|
|
74
|
-
Code: ABCD-EFGH
|
|
73
|
+
https://zas.red/agents/pair?p=...#port=53211
|
|
75
74
|
Fingerprint: 1a2b 3c4d 5e6f 7a8b
|
|
76
75
|
Waiting for approval… (expires in 10 minutes)
|
|
77
76
|
```
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
Signed in, name the agent and tick the channels it may use — sending is the
|
|
79
|
+
default, reading is a separate switch — and approve. Approval creates
|
|
80
|
+
nothing by itself: the page hands a one-time claim code to this terminal
|
|
81
|
+
over `127.0.0.1` (the port in the link), and the agent exists only once the
|
|
82
|
+
terminal claims with that code and the secret it holds. A link that reached
|
|
83
|
+
somebody else is approved on their machine, where nothing listens, and
|
|
84
|
+
expires with nothing created.
|
|
85
|
+
|
|
86
|
+
If the browser cannot reach the terminal — the link was opened on a phone,
|
|
87
|
+
or the browser refused the local connection — the page shows the code and
|
|
88
|
+
the terminal asks for it. Type it there and nowhere else: with that code,
|
|
89
|
+
another terminal that started a pairing could claim it. Pass `--no-open` or
|
|
90
|
+
set `ZAS_NO_OPEN=1` to keep the browser closed; the link is printed either
|
|
91
|
+
way. The pairing is good for ten minutes before approval and five after;
|
|
92
|
+
past that, run the command again.
|
|
93
|
+
|
|
94
|
+
You can also start the flow from the coding agent with the `zas_pair` tool:
|
|
95
|
+
the first call hands back the URL, a later call says whether the approval
|
|
96
|
+
landed, and if the page shows a code, a call with `code` claims with it.
|
|
88
97
|
|
|
89
98
|
## Why you can trust it
|
|
90
99
|
|
|
@@ -98,9 +107,11 @@ source, the sentence says so.
|
|
|
98
107
|
account key, and the key-derivation service refuses account-key derivation to
|
|
99
108
|
an agent *(server-side)*.
|
|
100
109
|
- **You approve it, and you choose the channels.** Pairing never
|
|
101
|
-
auto-approves. The approval page shows the harness, the host
|
|
102
|
-
fingerprint and
|
|
103
|
-
|
|
110
|
+
auto-approves. The approval page shows the harness, the host and the key
|
|
111
|
+
fingerprint, and nothing exists until the terminal that started the
|
|
112
|
+
pairing claims it with a code only the approving page received
|
|
113
|
+
*(server-side)*. A pairing link sent to you by someone else creates
|
|
114
|
+
nothing on your account.
|
|
104
115
|
- **It holds one key per granted channel, and no key for any other.** Each
|
|
105
116
|
grant carries that channel's key sealed to the agent's X25519 public key. A
|
|
106
117
|
channel you did not grant has no key here to decrypt with, and the server
|
|
@@ -193,7 +204,7 @@ stack trace.
|
|
|
193
204
|
| Tool | What it does |
|
|
194
205
|
| --- | --- |
|
|
195
206
|
| `zas_status` | Says whether this machine is paired with a Zas account, and lists the owner's channels this agent may send to or read from. |
|
|
196
|
-
| `zas_pair` | Pairs this machine with a Zas account. The first call returns a URL
|
|
207
|
+
| `zas_pair` | Pairs this machine with a Zas account. The first call returns a URL for the owner to open; a later call says whether they approved. If the page shows a code, a call with `code` claims with it. |
|
|
197
208
|
| `zas_send_file` | Sends a file from this machine into one of the owner's channels. Returns the item id, or a job id when the upload takes longer than a minute. |
|
|
198
209
|
| `zas_send_note` | Sends a note — plain text, or a code snippet with its language — into one of the owner's channels. |
|
|
199
210
|
| `zas_list_items` | Lists the most recent items in one of the owner's channels. Needs a grant that includes reading. |
|
|
@@ -232,8 +243,8 @@ crash mid-write cannot leave half a file behind:
|
|
|
232
243
|
|
|
233
244
|
- `identity.json` — the agent uid, the owner uid, the name, and the two key
|
|
234
245
|
pairs. Back it up like a private key, or delete it and pair again.
|
|
235
|
-
- `pending.json` — a pairing that has not been
|
|
236
|
-
|
|
246
|
+
- `pending.json` — a pairing that has not been claimed yet. Removed on
|
|
247
|
+
completion, and on a pairing that expired or was cancelled.
|
|
237
248
|
- `grants.json` — a one-minute cache of `GET /v1/agents/me`: which channels,
|
|
238
249
|
and the sealed key for each. The channel name stays encrypted here.
|
|
239
250
|
Disposable.
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createRequire as __cr } from "node:module"; const require = __cr(import
|
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
5
|
import { realpathSync } from "node:fs";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
9
|
|
|
@@ -76,6 +77,17 @@ var AGENT_TOKEN_TTL_SEC = 3600;
|
|
|
76
77
|
var AGENT_LAST_SEEN_BUCKET_MS = 5 * 60 * 1e3;
|
|
77
78
|
var AGENT_HOST_MAX = 60;
|
|
78
79
|
var AGENT_CHALLENGE_DOMAIN = "ZAS-AGENT-CHALLENGE-V1";
|
|
80
|
+
var AGENT_PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
81
|
+
var AGENT_PAIRING_CODE_LENGTH = 8;
|
|
82
|
+
var AGENT_CLAIM_TTL_MS = 5 * 60 * 1e3;
|
|
83
|
+
function keepPairingCodeChars(raw) {
|
|
84
|
+
return raw.toUpperCase().split("").filter((c) => AGENT_PAIRING_CODE_ALPHABET.includes(c)).join("");
|
|
85
|
+
}
|
|
86
|
+
function normalizePairingCode(raw) {
|
|
87
|
+
if (typeof raw !== "string") return "";
|
|
88
|
+
const kept = keepPairingCodeChars(raw);
|
|
89
|
+
return kept.length === AGENT_PAIRING_CODE_LENGTH ? kept : "";
|
|
90
|
+
}
|
|
79
91
|
var text = new TextEncoder();
|
|
80
92
|
function field(bytes) {
|
|
81
93
|
const length = new Uint8Array(4);
|
|
@@ -156,6 +168,11 @@ var SENTENCES = {
|
|
|
156
168
|
duplicate: "That item is already in the channel.",
|
|
157
169
|
pairing_expired: "The pairing expired. Run \u201Czas-agent pair\u201D again.",
|
|
158
170
|
pairing_cancelled: "The owner cancelled the pairing.",
|
|
171
|
+
claim_mismatch: "The code does not match. Try again.",
|
|
172
|
+
pairing_not_approved: "Nobody has approved this pairing yet.",
|
|
173
|
+
pairing_claimed: "This pairing was already claimed. Pair again with \u201Czas-agent pair\u201D.",
|
|
174
|
+
agent_limit: "The account cannot take another agent. The owner deletes one from Settings \u2192 Agents.",
|
|
175
|
+
grant_limit: "The plan allows fewer channels per agent than this pairing grants.",
|
|
159
176
|
feature_disabled: "Agents are not enabled for this account yet.",
|
|
160
177
|
upload_failed: "The upload failed. Try again.",
|
|
161
178
|
oprf_failed: "Zas did not answer correctly while preparing the file. Try again.",
|
|
@@ -278,12 +295,138 @@ function defaultEndpoints() {
|
|
|
278
295
|
};
|
|
279
296
|
}
|
|
280
297
|
|
|
298
|
+
// src/open.ts
|
|
299
|
+
import { spawn } from "node:child_process";
|
|
300
|
+
function openInBrowser(url, platform = process.platform) {
|
|
301
|
+
try {
|
|
302
|
+
const [command, args] = platform === "win32" ? ["cmd", ["/c", "start", "", url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
303
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
304
|
+
child.on("error", () => void 0);
|
|
305
|
+
child.unref();
|
|
306
|
+
} catch {
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
281
310
|
// src/pair.ts
|
|
282
311
|
import { hostname } from "node:os";
|
|
283
312
|
|
|
313
|
+
// src/claim-listener.ts
|
|
314
|
+
import { createServer } from "node:http";
|
|
315
|
+
var BODY_MAX = 1024;
|
|
316
|
+
function readBody(req) {
|
|
317
|
+
return new Promise((resolve2) => {
|
|
318
|
+
let size = 0;
|
|
319
|
+
let resolved = false;
|
|
320
|
+
const chunks = [];
|
|
321
|
+
req.on("data", (chunk) => {
|
|
322
|
+
size += chunk.length;
|
|
323
|
+
if (size > BODY_MAX) {
|
|
324
|
+
if (!resolved) {
|
|
325
|
+
resolved = true;
|
|
326
|
+
resolve2(null);
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
chunks.push(chunk);
|
|
331
|
+
});
|
|
332
|
+
req.on("end", () => {
|
|
333
|
+
if (!resolved) {
|
|
334
|
+
resolved = true;
|
|
335
|
+
resolve2(Buffer.concat(chunks).toString("utf8"));
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
req.on("error", () => {
|
|
339
|
+
if (!resolved) {
|
|
340
|
+
resolved = true;
|
|
341
|
+
resolve2(null);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async function startClaimListener(opts) {
|
|
347
|
+
let taken = false;
|
|
348
|
+
const server = createServer(async (req, res) => {
|
|
349
|
+
const origin = String(req.headers.origin ?? "");
|
|
350
|
+
const json = (status, body2) => {
|
|
351
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
352
|
+
res.end(JSON.stringify(body2));
|
|
353
|
+
};
|
|
354
|
+
if (req.url !== "/claim") {
|
|
355
|
+
json(404, { error: "not_found" });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (req.method === "OPTIONS") {
|
|
359
|
+
if (origin !== opts.webOrigin) {
|
|
360
|
+
res.writeHead(403);
|
|
361
|
+
res.end();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
res.writeHead(204, {
|
|
365
|
+
"Access-Control-Allow-Origin": origin,
|
|
366
|
+
"Access-Control-Allow-Methods": "POST",
|
|
367
|
+
"Access-Control-Allow-Headers": "content-type",
|
|
368
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
369
|
+
"Access-Control-Max-Age": "300",
|
|
370
|
+
Vary: "Origin"
|
|
371
|
+
});
|
|
372
|
+
res.end();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (req.method !== "POST") {
|
|
376
|
+
json(404, { error: "not_found" });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (origin !== opts.webOrigin) {
|
|
380
|
+
json(403, { error: "forbidden" });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
384
|
+
res.setHeader("Vary", "Origin");
|
|
385
|
+
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
386
|
+
json(400, { error: "bad_request" });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const raw = await readBody(req);
|
|
390
|
+
let body = {};
|
|
391
|
+
try {
|
|
392
|
+
body = raw === null ? {} : JSON.parse(raw);
|
|
393
|
+
} catch {
|
|
394
|
+
body = {};
|
|
395
|
+
}
|
|
396
|
+
if (typeof body !== "object" || body === null || body.pairing_id !== opts.pairingId || typeof body.code !== "string") {
|
|
397
|
+
json(400, { error: "bad_request" });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (taken) {
|
|
401
|
+
json(409, { error: "claim_taken" });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
taken = true;
|
|
405
|
+
const outcome = await opts.onCode(body.code);
|
|
406
|
+
if ("error" in outcome) json(outcome.status, { error: outcome.error });
|
|
407
|
+
else json(200, { status: "claimed" });
|
|
408
|
+
});
|
|
409
|
+
await new Promise((resolve2, reject) => {
|
|
410
|
+
server.once("error", reject);
|
|
411
|
+
server.listen(0, "127.0.0.1", () => {
|
|
412
|
+
server.off("error", reject);
|
|
413
|
+
resolve2();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
const address = server.address();
|
|
417
|
+
return {
|
|
418
|
+
host: address.address,
|
|
419
|
+
port: address.port,
|
|
420
|
+
close: () => new Promise((resolve2) => {
|
|
421
|
+
server.closeAllConnections();
|
|
422
|
+
server.close(() => resolve2());
|
|
423
|
+
})
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
284
427
|
// src/client.ts
|
|
285
428
|
var REFRESH_MARGIN_MS = 5 * 60 * 1e3;
|
|
286
|
-
async function
|
|
429
|
+
async function readBody2(res) {
|
|
287
430
|
const text2 = await res.text();
|
|
288
431
|
if (!text2) return {};
|
|
289
432
|
try {
|
|
@@ -302,7 +445,7 @@ function jsonInit(method, body, headers) {
|
|
|
302
445
|
async function apiPublic(base, method, path, body, headers = {}, fetchImpl) {
|
|
303
446
|
const call = fetchImpl ?? ((input, init) => fetch(input, init));
|
|
304
447
|
const res = await call(`${base}${path}`, jsonInit(method, body, headers));
|
|
305
|
-
const parsed = res.status === 204 ? void 0 : await
|
|
448
|
+
const parsed = res.status === 204 ? void 0 : await readBody2(res);
|
|
306
449
|
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
307
450
|
return parsed;
|
|
308
451
|
}
|
|
@@ -368,7 +511,7 @@ var ZasClient = class _ZasClient {
|
|
|
368
511
|
}
|
|
369
512
|
async api(method, path, body, headers = {}) {
|
|
370
513
|
const res = await this.authedRetrying(`${this.identity.api_base}/v1${path}`, method, body, headers);
|
|
371
|
-
const parsed = res.status === 204 ? void 0 : await
|
|
514
|
+
const parsed = res.status === 204 ? void 0 : await readBody2(res);
|
|
372
515
|
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
373
516
|
return parsed;
|
|
374
517
|
}
|
|
@@ -391,7 +534,7 @@ var ZasClient = class _ZasClient {
|
|
|
391
534
|
}
|
|
392
535
|
async oprfEvaluate(blinded) {
|
|
393
536
|
const res = await this.authedRetrying(`${this.identity.oprf_base}/evaluate`, "POST", { blinded }, {});
|
|
394
|
-
const parsed = await
|
|
537
|
+
const parsed = await readBody2(res);
|
|
395
538
|
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
396
539
|
return parsed.evaluated ?? [];
|
|
397
540
|
}
|
|
@@ -402,7 +545,7 @@ var ZasClient = class _ZasClient {
|
|
|
402
545
|
const base = _ZasClient.firestoreBase(this.identity.firestore_project);
|
|
403
546
|
const url = `${base}${parentPath ? `/${parentPath}` : ""}:runQuery`;
|
|
404
547
|
const res = await this.authed(url, "POST", { structuredQuery: query }, {});
|
|
405
|
-
const parsed = await
|
|
548
|
+
const parsed = await readBody2(res);
|
|
406
549
|
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
407
550
|
if (!Array.isArray(parsed)) return [];
|
|
408
551
|
return parsed.filter((row) => !!row && typeof row === "object" && "document" in row).map((row) => row.document);
|
|
@@ -427,10 +570,10 @@ function packageName() {
|
|
|
427
570
|
return true ? "zas-agent" : "zas-agent";
|
|
428
571
|
}
|
|
429
572
|
function claudeSnippet(profile) {
|
|
430
|
-
return `claude mcp add zas -- npx -y ${packageName()} --profile ${profile}`;
|
|
573
|
+
return `claude mcp add zas "--" npx -y ${packageName()} --profile ${profile}`;
|
|
431
574
|
}
|
|
432
575
|
function codexSnippet(profile) {
|
|
433
|
-
return `codex mcp add zas -- npx -y ${packageName()} --profile ${profile}`;
|
|
576
|
+
return `codex mcp add zas "--" npx -y ${packageName()} --profile ${profile}`;
|
|
434
577
|
}
|
|
435
578
|
function kindForProfile(profile) {
|
|
436
579
|
if (profile === "claude-code") return "claude_code";
|
|
@@ -440,13 +583,23 @@ function kindForProfile(profile) {
|
|
|
440
583
|
|
|
441
584
|
// src/pair.ts
|
|
442
585
|
var POLL_INTERVAL_MS = 2e3;
|
|
443
|
-
|
|
444
|
-
|
|
586
|
+
var NO_TTY_LINE = "This terminal cannot take a code. If the page shows one, run zas-agent pair again from an interactive terminal.";
|
|
587
|
+
var CODE_SHAPE_LINE = "The code is eight letters and digits, like ABCD-EFGH.";
|
|
588
|
+
function pairUrl(webBase, pairingId, port) {
|
|
589
|
+
const link = `${webBase}/agents/pair?p=${pairingId}`;
|
|
590
|
+
return port ? `${link}#port=${port}` : link;
|
|
445
591
|
}
|
|
446
|
-
function
|
|
447
|
-
return
|
|
592
|
+
function webOriginOf(webBase) {
|
|
593
|
+
return new URL(webBase).origin;
|
|
448
594
|
}
|
|
449
595
|
var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
596
|
+
function deferred() {
|
|
597
|
+
let resolve2;
|
|
598
|
+
const promise = new Promise((r) => {
|
|
599
|
+
resolve2 = r;
|
|
600
|
+
});
|
|
601
|
+
return { promise, resolve: resolve2 };
|
|
602
|
+
}
|
|
450
603
|
async function runPair(opts) {
|
|
451
604
|
const sleep2 = opts.sleep ?? defaultSleep;
|
|
452
605
|
const now = opts.now ?? (() => Date.now());
|
|
@@ -463,67 +616,152 @@ async function runPair(opts) {
|
|
|
463
616
|
opts.apiBase,
|
|
464
617
|
"POST",
|
|
465
618
|
"/v1/agents/pairings",
|
|
466
|
-
{ kind: opts.kind, host, x25519_public: keys.x25519_public, p256_public: keys.p256_public },
|
|
619
|
+
{ kind: opts.kind, host, x25519_public: keys.x25519_public, p256_public: keys.p256_public, claim: true },
|
|
467
620
|
{},
|
|
468
621
|
opts.fetch
|
|
469
622
|
);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
623
|
+
let claimed = null;
|
|
624
|
+
let inFlight = null;
|
|
625
|
+
const wake = deferred();
|
|
626
|
+
const claimOnce = (code) => {
|
|
627
|
+
if (claimed) return Promise.resolve({ error: "pairing_claimed", status: 409 });
|
|
628
|
+
if (inFlight) return inFlight;
|
|
629
|
+
inFlight = (async () => {
|
|
630
|
+
try {
|
|
631
|
+
const answer = await apiPublic(
|
|
632
|
+
opts.apiBase,
|
|
633
|
+
"POST",
|
|
634
|
+
`/v1/agents/pairings/${created.pairing_id}/claim`,
|
|
635
|
+
{ code },
|
|
636
|
+
{ "X-Zas-Poll-Secret": created.poll_secret },
|
|
637
|
+
opts.fetch
|
|
638
|
+
);
|
|
639
|
+
claimed = { agent_uid: answer.agent_uid, owner_uid: answer.owner_uid };
|
|
640
|
+
wake.resolve();
|
|
641
|
+
return { status: "claimed" };
|
|
642
|
+
} catch (err) {
|
|
643
|
+
if (err instanceof ZasError) return { error: err.code, status: err.status };
|
|
644
|
+
return { error: "network", status: 502 };
|
|
645
|
+
} finally {
|
|
646
|
+
inFlight = null;
|
|
647
|
+
}
|
|
648
|
+
})();
|
|
649
|
+
return inFlight;
|
|
483
650
|
};
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
opts.log(
|
|
487
|
-
[
|
|
488
|
-
"Open this page signed in to your Zas account:",
|
|
489
|
-
` ${pairUrl(opts.webBase, created.pairing_id)}`,
|
|
490
|
-
`Code: ${formatCode(created.code)}`,
|
|
491
|
-
`Fingerprint: ${agentFingerprintShort(created.fingerprint)}`,
|
|
492
|
-
`Waiting for approval\u2026 (expires in ${minutesLeft} minutes)`
|
|
493
|
-
].join("\n")
|
|
494
|
-
);
|
|
495
|
-
let approved = null;
|
|
496
|
-
for (; ; ) {
|
|
497
|
-
let answer;
|
|
651
|
+
let listener = null;
|
|
652
|
+
if (opts.listen !== false) {
|
|
498
653
|
try {
|
|
499
|
-
|
|
500
|
-
opts.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
);
|
|
507
|
-
} catch (err) {
|
|
508
|
-
if (!(err instanceof ZasError) || err.code !== "rate_limited") throw err;
|
|
509
|
-
if (now() >= created.expires_at) throw new ZasError("pairing_expired", 410);
|
|
510
|
-
await sleep2(err.retryAfterMs ?? 1e4);
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
if (answer.status === "approved") {
|
|
514
|
-
approved = { agent_uid: answer.agent_uid, owner_uid: answer.owner_uid };
|
|
515
|
-
break;
|
|
654
|
+
listener = await startClaimListener({
|
|
655
|
+
webOrigin: webOriginOf(opts.webBase),
|
|
656
|
+
pairingId: created.pairing_id,
|
|
657
|
+
onCode: (code) => claimOnce(normalizePairingCode(code) || code)
|
|
658
|
+
});
|
|
659
|
+
} catch {
|
|
660
|
+
listener = null;
|
|
516
661
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
662
|
+
}
|
|
663
|
+
let askFailed = null;
|
|
664
|
+
try {
|
|
665
|
+
const port = listener?.port;
|
|
666
|
+
const link = pairUrl(opts.webBase, created.pairing_id, port);
|
|
667
|
+
const pending = {
|
|
668
|
+
version: 1,
|
|
669
|
+
profile: opts.profile,
|
|
670
|
+
pairing_id: created.pairing_id,
|
|
671
|
+
poll_secret: created.poll_secret,
|
|
672
|
+
fingerprint: created.fingerprint,
|
|
673
|
+
expires_at: created.expires_at,
|
|
674
|
+
kind: opts.kind,
|
|
675
|
+
host,
|
|
676
|
+
...keys,
|
|
677
|
+
...endpoints,
|
|
678
|
+
created_at: now(),
|
|
679
|
+
protocol: 2,
|
|
680
|
+
...port ? { port } : {}
|
|
681
|
+
};
|
|
682
|
+
savePending(opts.profile, pending);
|
|
683
|
+
const minutesLeft = Math.max(0, Math.round((created.expires_at - now()) / 6e4));
|
|
684
|
+
opts.log(
|
|
685
|
+
[
|
|
686
|
+
"Open this page signed in to your Zas account:",
|
|
687
|
+
` ${link}`,
|
|
688
|
+
`Fingerprint: ${agentFingerprintShort(created.fingerprint)}`,
|
|
689
|
+
`Waiting for approval\u2026 (expires in ${minutesLeft} minutes)`
|
|
690
|
+
].join("\n")
|
|
691
|
+
);
|
|
692
|
+
opts.open?.(link);
|
|
693
|
+
let asking = false;
|
|
694
|
+
let toldNoTty = false;
|
|
695
|
+
const askLoop = async () => {
|
|
696
|
+
try {
|
|
697
|
+
while (!claimed) {
|
|
698
|
+
const code = normalizePairingCode(await opts.askCode());
|
|
699
|
+
if (!code) {
|
|
700
|
+
opts.log(CODE_SHAPE_LINE);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const outcome = await claimOnce(code);
|
|
704
|
+
if (!("error" in outcome) || outcome.error === "pairing_claimed") return;
|
|
705
|
+
if (outcome.error === "claim_mismatch") {
|
|
706
|
+
opts.log(humanSentence(new ZasError("claim_mismatch", 403)));
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
throw new ZasError(outcome.error, outcome.status);
|
|
710
|
+
}
|
|
711
|
+
} catch (e) {
|
|
712
|
+
askFailed = e;
|
|
713
|
+
wake.resolve();
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
for (; ; ) {
|
|
717
|
+
if (claimed) break;
|
|
718
|
+
if (askFailed) throw askFailed;
|
|
719
|
+
let answer;
|
|
720
|
+
try {
|
|
721
|
+
answer = await apiPublic(
|
|
722
|
+
opts.apiBase,
|
|
723
|
+
"POST",
|
|
724
|
+
`/v1/agents/pairings/${created.pairing_id}/poll`,
|
|
725
|
+
void 0,
|
|
726
|
+
{ "X-Zas-Poll-Secret": created.poll_secret },
|
|
727
|
+
opts.fetch
|
|
728
|
+
);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
if (!(err instanceof ZasError) || err.code !== "rate_limited") throw err;
|
|
731
|
+
if (now() >= created.expires_at) throw new ZasError("pairing_expired", 410);
|
|
732
|
+
await Promise.race([sleep2(err.retryAfterMs ?? 1e4), wake.promise]);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (claimed) break;
|
|
736
|
+
if (answer.status === "claimed") {
|
|
737
|
+
claimed = { agent_uid: answer.agent_uid, owner_uid: answer.owner_uid };
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
if (answer.status === "expired" || answer.status === "cancelled") {
|
|
741
|
+
clearPending(opts.profile);
|
|
742
|
+
throw new ZasError(`pairing_${answer.status}`, 410);
|
|
743
|
+
}
|
|
744
|
+
if (answer.status === "approved") {
|
|
745
|
+
if (opts.askCode && !asking) {
|
|
746
|
+
asking = true;
|
|
747
|
+
void askLoop();
|
|
748
|
+
}
|
|
749
|
+
if (!opts.askCode && !toldNoTty) {
|
|
750
|
+
toldNoTty = true;
|
|
751
|
+
opts.log(NO_TTY_LINE);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
await Promise.race([sleep2(POLL_INTERVAL_MS), wake.promise]);
|
|
520
755
|
}
|
|
521
|
-
|
|
756
|
+
} finally {
|
|
757
|
+
await listener?.close();
|
|
522
758
|
}
|
|
759
|
+
if (askFailed) throw askFailed;
|
|
760
|
+
if (!claimed) throw new ZasError("pairing_expired", 410);
|
|
523
761
|
const baseIdentity = {
|
|
524
762
|
version: 1,
|
|
525
|
-
agent_uid:
|
|
526
|
-
owner_uid:
|
|
763
|
+
agent_uid: claimed.agent_uid,
|
|
764
|
+
owner_uid: claimed.owner_uid,
|
|
527
765
|
name: "",
|
|
528
766
|
kind: opts.kind,
|
|
529
767
|
host,
|
|
@@ -1631,7 +1869,7 @@ async function sendNote(ctx, input) {
|
|
|
1631
1869
|
|
|
1632
1870
|
// src/server.ts
|
|
1633
1871
|
function agentVersion() {
|
|
1634
|
-
return true ? "0.
|
|
1872
|
+
return true ? "0.2.1" : "0.0.0-dev";
|
|
1635
1873
|
}
|
|
1636
1874
|
var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
|
|
1637
1875
|
var PAIR_ANNOUNCE_MS = 15e3;
|
|
@@ -1703,10 +1941,14 @@ function buildServer(profile, deps = {}) {
|
|
|
1703
1941
|
});
|
|
1704
1942
|
let pairing = null;
|
|
1705
1943
|
server.registerTool("zas_pair", {
|
|
1706
|
-
description: "Pair this machine with a Zas account. The first call returns a URL
|
|
1707
|
-
|
|
1944
|
+
description: "Pair this machine with a Zas account. The first call returns a URL for the owner to open; a later call says whether they approved. If the page shows a code, call again with `code`." + AGENT_CUE,
|
|
1945
|
+
inputSchema: {
|
|
1946
|
+
code: z.string().optional().describe("The code the pairing page shows when the browser could not reach this machine.")
|
|
1947
|
+
}
|
|
1948
|
+
}, async (input) => {
|
|
1708
1949
|
if (!pairing) {
|
|
1709
|
-
|
|
1950
|
+
if (input.code !== void 0) return failed(new ZasError("pairing_not_approved", 409));
|
|
1951
|
+
const state = { logs: [], status: "running", ask: null };
|
|
1710
1952
|
pairing = state;
|
|
1711
1953
|
(deps.runPair ?? runPair)({
|
|
1712
1954
|
profile,
|
|
@@ -1717,7 +1959,13 @@ function buildServer(profile, deps = {}) {
|
|
|
1717
1959
|
apiBase: defaultEndpoints().api_base,
|
|
1718
1960
|
log: (line) => {
|
|
1719
1961
|
state.logs.push(line);
|
|
1720
|
-
}
|
|
1962
|
+
},
|
|
1963
|
+
// The person cannot type into this process; the coding agent relays
|
|
1964
|
+
// the code as an argument, and this promise is what it answers.
|
|
1965
|
+
askCode: () => new Promise((resolve2) => {
|
|
1966
|
+
state.ask = resolve2;
|
|
1967
|
+
}),
|
|
1968
|
+
...process.env.ZAS_NO_OPEN === "1" ? {} : { open: openInBrowser }
|
|
1721
1969
|
}).then(
|
|
1722
1970
|
(identity) => {
|
|
1723
1971
|
state.status = "paired";
|
|
@@ -1744,6 +1992,25 @@ function buildServer(profile, deps = {}) {
|
|
|
1744
1992
|
pairing = null;
|
|
1745
1993
|
return failed(error);
|
|
1746
1994
|
}
|
|
1995
|
+
if (input.code !== void 0) {
|
|
1996
|
+
const state = pairing;
|
|
1997
|
+
const ask = state.ask;
|
|
1998
|
+
if (!ask) return failed(new ZasError("pairing_not_approved", 409));
|
|
1999
|
+
state.ask = null;
|
|
2000
|
+
ask(normalizePairingCode(input.code) || input.code);
|
|
2001
|
+
const until = Date.now() + (deps.announceMs ?? PAIR_ANNOUNCE_MS);
|
|
2002
|
+
while (state.status === "running" && state.ask === null && Date.now() < until) {
|
|
2003
|
+
await delay(10);
|
|
2004
|
+
}
|
|
2005
|
+
if (state.status === "paired") return text2(`paired as ${state.identity?.name ?? ""}`.trim());
|
|
2006
|
+
if (state.status === "failed") {
|
|
2007
|
+
const error = state.error;
|
|
2008
|
+
pairing = null;
|
|
2009
|
+
return failed(error);
|
|
2010
|
+
}
|
|
2011
|
+
if (state.ask !== null) return failed(new ZasError("claim_mismatch", 403));
|
|
2012
|
+
return text2(["pending", ...state.logs].join("\n"));
|
|
2013
|
+
}
|
|
1747
2014
|
return text2(["pending", ...pairing.logs].join("\n"));
|
|
1748
2015
|
});
|
|
1749
2016
|
server.registerTool("zas_send_file", {
|
|
@@ -1846,7 +2113,8 @@ function parseArgs(argv) {
|
|
|
1846
2113
|
const kind = take();
|
|
1847
2114
|
if (!isAgentKind(kind)) return { ...parsed, command: "help", unknown: `--kind ${kind}` };
|
|
1848
2115
|
parsed.kind = kind;
|
|
1849
|
-
} else if (name === "--
|
|
2116
|
+
} else if (name === "--no-open") parsed.noOpen = true;
|
|
2117
|
+
else if (name === "--host") parsed.host = take() || void 0;
|
|
1850
2118
|
else return { ...parsed, command: "help", unknown: arg };
|
|
1851
2119
|
}
|
|
1852
2120
|
return parsed;
|
|
@@ -1856,7 +2124,7 @@ var USAGE = [
|
|
|
1856
2124
|
"",
|
|
1857
2125
|
" zas-agent [--profile <name>] serve the MCP tools over stdio",
|
|
1858
2126
|
" zas-agent pair [--profile <name>] pair this machine with a Zas account",
|
|
1859
|
-
" [--kind claude_code|codex|other] [--host <name>]",
|
|
2127
|
+
" [--kind claude_code|codex|other] [--host <name>] [--no-open]",
|
|
1860
2128
|
" zas-agent --version"
|
|
1861
2129
|
].join("\n");
|
|
1862
2130
|
async function main(argv, log = (l) => console.error(l)) {
|
|
@@ -1875,6 +2143,14 @@ async function main(argv, log = (l) => console.error(l)) {
|
|
|
1875
2143
|
return args.unknown ? 2 : 0;
|
|
1876
2144
|
}
|
|
1877
2145
|
if (args.command === "pair") {
|
|
2146
|
+
let rl = null;
|
|
2147
|
+
const askCode = process.stdin.isTTY ? () => {
|
|
2148
|
+
rl ??= createInterface({ input: process.stdin, output: process.stderr });
|
|
2149
|
+
return new Promise((resolve2) => {
|
|
2150
|
+
rl.question("Type the code shown in the browser: ", resolve2);
|
|
2151
|
+
});
|
|
2152
|
+
} : void 0;
|
|
2153
|
+
const noOpen = args.noOpen === true || process.env.ZAS_NO_OPEN === "1";
|
|
1878
2154
|
try {
|
|
1879
2155
|
await runPair({
|
|
1880
2156
|
profile: args.profile,
|
|
@@ -1882,12 +2158,16 @@ async function main(argv, log = (l) => console.error(l)) {
|
|
|
1882
2158
|
...args.host !== void 0 ? { host: args.host } : {},
|
|
1883
2159
|
webBase: process.env.ZAS_WEB_BASE || "https://zas.red",
|
|
1884
2160
|
apiBase: defaultEndpoints().api_base,
|
|
1885
|
-
log
|
|
2161
|
+
log,
|
|
2162
|
+
...askCode ? { askCode } : {},
|
|
2163
|
+
...noOpen ? {} : { open: openInBrowser }
|
|
1886
2164
|
});
|
|
1887
2165
|
} catch (e) {
|
|
1888
2166
|
const err = e instanceof ZasError ? e : new ZasError("internal", 0, String(e));
|
|
1889
2167
|
log(humanSentence(err));
|
|
1890
2168
|
return 1;
|
|
2169
|
+
} finally {
|
|
2170
|
+
rl?.close();
|
|
1891
2171
|
}
|
|
1892
2172
|
return 0;
|
|
1893
2173
|
}
|