zas-agent 0.4.0 → 0.5.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/CHANGELOG.md +28 -0
- package/README.md +21 -15
- package/dist/cli.js +705 -81
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `zas_receive_direct` receives a file the owner sends through Directo,
|
|
15
|
+
straight onto this machine. It waits for the offer, takes it, and writes
|
|
16
|
+
the file to disk; nothing is stored anywhere. Receiving is reading, so it
|
|
17
|
+
needs a grant with `read`, and the channel has to be in Directo mode. The
|
|
18
|
+
call returns the result, or a job id after a minute; `zas_jobs` follows the
|
|
19
|
+
phases `waiting`, `connecting`, `flight`, `finishing`.
|
|
20
|
+
- `zas_receive_direct_fallback` finishes a receive that failed in flight,
|
|
21
|
+
when the sender chose reliable delivery for it: the encrypted copy is
|
|
22
|
+
downloaded and decrypted onto the same destination.
|
|
23
|
+
- New error codes: `no_offer` and `offer_taken`.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- The agent claims an offer only inside a tool call. It never watches a
|
|
28
|
+
channel: an exchange takes two devices and the first claim wins, so a
|
|
29
|
+
watcher would take files meant for the owner's own phone.
|
|
30
|
+
- A received file never overwrites an existing one. It is written 0600
|
|
31
|
+
through a temporary name and renamed only once the transfer is verified,
|
|
32
|
+
so a run that breaks leaves nothing that reads as complete. The answered
|
|
33
|
+
`path` is where the bytes actually went.
|
|
34
|
+
- `zas_status` says `receive (Directo)` where it said `read` for a channel
|
|
35
|
+
in Directo mode: such a channel stores nothing, so there is no list of
|
|
36
|
+
items behind the grant, only live offers to take.
|
|
37
|
+
|
|
10
38
|
## [0.4.0] - 2026-09-03
|
|
11
39
|
|
|
12
40
|
### Added
|
package/README.md
CHANGED
|
@@ -25,9 +25,9 @@ scope to the channels you choose, and that you can revoke.
|
|
|
25
25
|
|
|
26
26
|
## What it does
|
|
27
27
|
|
|
28
|
-
Your coding agent gets
|
|
29
|
-
you picked, send a file live through Directo, list what is in that
|
|
30
|
-
and pull one item back onto disk.
|
|
28
|
+
Your coding agent gets eleven tools. It can send a file or a note into a channel
|
|
29
|
+
you picked, send or receive a file live through Directo, list what is in that
|
|
30
|
+
channel, and pull one item back onto disk.
|
|
31
31
|
Everything it sends is encrypted on your machine before it leaves, lands in your
|
|
32
32
|
account, and is marked in the channel as sent by that agent. The agent has an
|
|
33
33
|
identity of its own and never holds your account key: pairing mints a key pair
|
|
@@ -151,8 +151,10 @@ ones marked *(server-side)* are enforced by the Zas server.
|
|
|
151
151
|
- **No stored sending into a view-only channel, and none into a channel in
|
|
152
152
|
Directo mode.** Both are refused before a byte is uploaded. A channel in
|
|
153
153
|
Directo mode takes `zas_send_direct`, a live transfer that stores nothing.
|
|
154
|
-
- **No receiving
|
|
155
|
-
|
|
154
|
+
- **No receiving unless the grant says read.** Receiving through Directo
|
|
155
|
+
writes a file onto this machine, so `zas_receive_direct` takes the same
|
|
156
|
+
`read` switch as listing. And it only ever runs inside a tool call: the
|
|
157
|
+
agent never watches your channels, so nothing arrives unasked.
|
|
156
158
|
- **Nothing outside its allowlist.** The API refuses an agent on every route
|
|
157
159
|
that is not on a short, explicit list, and Firestore rules refuse it your
|
|
158
160
|
account document, your devices, and any channel without an active read grant
|
|
@@ -182,9 +184,11 @@ string ever reaches a terminal.
|
|
|
182
184
|
| `direct_mode` | That channel is in Directo mode. |
|
|
183
185
|
| `not_direct_mode` | That channel is not in Directo mode. |
|
|
184
186
|
| `not_claimed` | Nobody received the file within ten minutes; the offer was withdrawn. |
|
|
185
|
-
| `
|
|
187
|
+
| `no_offer` | Nobody offered a file through Directo while the call waited. |
|
|
188
|
+
| `offer_taken` | Another device received that file first. |
|
|
189
|
+
| `direct_cancelled` | The offer was cancelled from the other side. |
|
|
186
190
|
| `direct_failed` | The Directo transfer failed in flight. |
|
|
187
|
-
| `direct_not_failed` | That job is not a Directo
|
|
191
|
+
| `direct_not_failed` | That job is not a Directo transfer that failed in flight. |
|
|
188
192
|
| `file_changed` | The file changed since the Directo offer. |
|
|
189
193
|
| `webrtc_unavailable` | The WebRTC engine could not be loaded on this machine. |
|
|
190
194
|
| `fallback_unavailable` | Reliable delivery is not available right now. |
|
|
@@ -220,17 +224,19 @@ stack trace.
|
|
|
220
224
|
| `zas_send_note` | Sends a note — plain text, or a code snippet with its language — into one of the owner's channels. |
|
|
221
225
|
| `zas_send_direct` | Sends a file through Directo: a live, device-to-device transfer into a channel in Directo mode. Nothing is stored. The owner presses Receive on another device within ten minutes; the call returns the result, or a job id after a minute. |
|
|
222
226
|
| `zas_send_direct_fallback` | After a Directo send failed in flight, delivers the same file through reliable delivery: encrypted on this machine, stored in Cloudflare R2 for up to 24 hours, off the owner's quota. The owner's choice; the model is told to ask. |
|
|
227
|
+
| `zas_receive_direct` | Receives a file the owner sends through Directo, onto this machine. Waits for the offer, takes it, and writes the file to disk. Needs a grant that includes reading, and a channel in Directo mode. Returns the path written, or a job id after a minute. |
|
|
228
|
+
| `zas_receive_direct_fallback` | After a Directo receive failed in flight, downloads the encrypted copy the sender chose to store, and decrypts it to the same destination. |
|
|
223
229
|
| `zas_list_items` | Lists the most recent items in one of the owner's channels. Needs a grant that includes reading. |
|
|
224
230
|
| `zas_get_item` | Fetches one item. A note comes back as text; a file is written to disk. It never overwrites, so the path it answers with can differ from the one you asked for. |
|
|
225
231
|
| `zas_jobs` | Lists the sends and Directo transfers this server started, newest first, with the phase each one reached — and where a `job_id` from a long send is redeemed. |
|
|
226
232
|
|
|
227
233
|
`channel` takes a channel name or a channel id. A name has to match exactly one
|
|
228
234
|
of the channels you granted; with exactly one grant, `zas_send_file`,
|
|
229
|
-
`zas_send_note` and `
|
|
235
|
+
`zas_send_note`, `zas_send_direct` and `zas_receive_direct` can leave it out.
|
|
230
236
|
|
|
231
237
|
Directo needs a native module, [node-datachannel](https://github.com/murat-dogan/node-datachannel),
|
|
232
238
|
WebRTC for Node. npm installs a prebuilt binary for Windows, macOS and Linux;
|
|
233
|
-
the module loads the first time
|
|
239
|
+
the module loads the first time a Directo tool runs, and a machine where it
|
|
234
240
|
cannot load answers `webrtc_unavailable`. Every other tool works without it.
|
|
235
241
|
|
|
236
242
|
Two things worth knowing before you point a model at your account:
|
|
@@ -238,13 +244,13 @@ Two things worth knowing before you point a model at your account:
|
|
|
238
244
|
- `zas_send_file` and `zas_send_direct` send any file this process can read —
|
|
239
245
|
`~/.ssh/id_rsa` and a `.env` included. Confirm with the owner before sending secrets, keys or
|
|
240
246
|
credentials. Its tool description says so, so the model reads it too.
|
|
241
|
-
- `zas_get_item`
|
|
242
|
-
directory when you leave `dest` out.
|
|
243
|
-
name that is taken gets a suffix, and the path
|
|
244
|
-
actually wrote.
|
|
247
|
+
- `zas_get_item` and `zas_receive_direct` write a new file under `dest`, or
|
|
248
|
+
under the system temp directory when you leave `dest` out. Neither ever
|
|
249
|
+
overwrites an existing file: a name that is taken gets a suffix, and the path
|
|
250
|
+
they answer with is the one they actually wrote.
|
|
245
251
|
|
|
246
|
-
Everything
|
|
247
|
-
machine. Revoking the agent stops
|
|
252
|
+
Everything those tools touch lands inside your own account and your own
|
|
253
|
+
machine. Revoking the agent stops all of them.
|
|
248
254
|
|
|
249
255
|
## Data on disk
|
|
250
256
|
|
package/dist/cli.js
CHANGED
|
@@ -167,9 +167,11 @@ var SENTENCES = {
|
|
|
167
167
|
direct_mode: "That channel is in Directo mode. Use zas_send_direct.",
|
|
168
168
|
not_direct_mode: "That channel is not in Directo mode. Use zas_send_file.",
|
|
169
169
|
not_claimed: "Nobody received the file within ten minutes. The offer was withdrawn.",
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
no_offer: "Nobody offered a file through Directo while this call waited. Ask the owner to press Send Direct, then call again.",
|
|
171
|
+
offer_taken: "Another device received that file first.",
|
|
172
|
+
direct_cancelled: "The offer was cancelled from the other side.",
|
|
173
|
+
direct_failed: "The Directo transfer failed ({path}). Ask the owner before you use zas_send_direct_fallback or zas_receive_direct_fallback.",
|
|
174
|
+
direct_not_failed: "That job is not a Directo transfer that failed in flight.",
|
|
173
175
|
file_changed: "The file changed since the Directo offer. Send it again.",
|
|
174
176
|
webrtc_unavailable: "The WebRTC engine (node-datachannel) could not be loaded on this machine.",
|
|
175
177
|
fallback_unavailable: "Reliable delivery is not available right now. Try again later.",
|
|
@@ -960,6 +962,14 @@ function encodeDone(digest) {
|
|
|
960
962
|
frame.set(body, 1);
|
|
961
963
|
return frame;
|
|
962
964
|
}
|
|
965
|
+
function encodeCredit(received) {
|
|
966
|
+
if (!Number.isSafeInteger(received) || received < 0) throw new Error("bad_credit");
|
|
967
|
+
const body = new TextEncoder().encode(String(received));
|
|
968
|
+
const frame = new Uint8Array(1 + body.length);
|
|
969
|
+
frame[0] = FRAME_CREDIT;
|
|
970
|
+
frame.set(body, 1);
|
|
971
|
+
return frame;
|
|
972
|
+
}
|
|
963
973
|
var DONE_FRAME = encodeDone();
|
|
964
974
|
var ABORT_FRAME = new Uint8Array([FRAME_ABORT]);
|
|
965
975
|
function parseFrame(data) {
|
|
@@ -1357,6 +1367,154 @@ function startSender(opts) {
|
|
|
1357
1367
|
}
|
|
1358
1368
|
};
|
|
1359
1369
|
}
|
|
1370
|
+
function startReceiver(opts) {
|
|
1371
|
+
let sinkRef = null;
|
|
1372
|
+
const s = session(
|
|
1373
|
+
opts.ice,
|
|
1374
|
+
(p) => {
|
|
1375
|
+
if (p === "failed") {
|
|
1376
|
+
try {
|
|
1377
|
+
sinkRef?.abort?.();
|
|
1378
|
+
} catch {
|
|
1379
|
+
}
|
|
1380
|
+
sinkRef = null;
|
|
1381
|
+
}
|
|
1382
|
+
opts.onPhase(p);
|
|
1383
|
+
},
|
|
1384
|
+
opts.onDiag,
|
|
1385
|
+
opts.iceTransportPolicy
|
|
1386
|
+
);
|
|
1387
|
+
s.setStall(() => void 0, DIRECT_CONNECT_TIMEOUT_MS * 2);
|
|
1388
|
+
opts.onPhase("connecting");
|
|
1389
|
+
const { pc } = s;
|
|
1390
|
+
const plumbing = signalPlumbing(pc, opts.send, () => s.fail("signal_send"), s.diag);
|
|
1391
|
+
let protocol = 1;
|
|
1392
|
+
let generation = 1;
|
|
1393
|
+
let dc;
|
|
1394
|
+
let pipeline = null;
|
|
1395
|
+
let total = 0;
|
|
1396
|
+
let got = 0;
|
|
1397
|
+
let queued = 0;
|
|
1398
|
+
let nextCredit = DIRECT_RECEIVE_WINDOW_BYTES;
|
|
1399
|
+
let hasherPromise = null;
|
|
1400
|
+
let finished = false;
|
|
1401
|
+
pc.ondatachannel = (e) => {
|
|
1402
|
+
dc = e.channel;
|
|
1403
|
+
dc.binaryType = "arraybuffer";
|
|
1404
|
+
dc.onopen = () => {
|
|
1405
|
+
s.connected();
|
|
1406
|
+
s.setPhase("flight");
|
|
1407
|
+
void pc.getStats().then((stats) => {
|
|
1408
|
+
const pair = pairFromStats(stats);
|
|
1409
|
+
if (!pair) return;
|
|
1410
|
+
s.diag.pairLocal = pair.local;
|
|
1411
|
+
s.diag.pairRemote = pair.remote;
|
|
1412
|
+
opts.onPath?.(pathOf(pair));
|
|
1413
|
+
}).catch(() => void 0);
|
|
1414
|
+
};
|
|
1415
|
+
dc.onmessage = (e2) => {
|
|
1416
|
+
try {
|
|
1417
|
+
const frame = parseFrame(e2.data);
|
|
1418
|
+
if (frame.type === "meta") {
|
|
1419
|
+
if (pipeline || !Number.isSafeInteger(frame.meta.size) || frame.meta.size < 0 || frame.meta.size > DIRECT_FILE_MAX_BYTES || typeof frame.meta.name !== "string" || frame.meta.name.length === 0 || typeof frame.meta.mime !== "string") {
|
|
1420
|
+
try {
|
|
1421
|
+
dc?.send(ABORT_FRAME);
|
|
1422
|
+
} catch {
|
|
1423
|
+
}
|
|
1424
|
+
s.fail(frame.meta.size > DIRECT_FILE_MAX_BYTES ? "too_big" : "protocol");
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (opts.expectedMeta && (frame.meta.name !== opts.expectedMeta.name || frame.meta.size !== opts.expectedMeta.size || frame.meta.mime !== opts.expectedMeta.mime)) {
|
|
1428
|
+
dc?.send(ABORT_FRAME);
|
|
1429
|
+
s.fail("protocol");
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
total = frame.meta.size;
|
|
1433
|
+
hasherPromise = createSHA256();
|
|
1434
|
+
opts.onMeta?.(frame.meta);
|
|
1435
|
+
pipeline = opts.sink(frame.meta).then((sink) => {
|
|
1436
|
+
sinkRef = sink;
|
|
1437
|
+
return sink;
|
|
1438
|
+
});
|
|
1439
|
+
pipeline.catch(() => s.fail("sink"));
|
|
1440
|
+
} else if (frame.type === "chunk") {
|
|
1441
|
+
if (!pipeline || finished) throw new Error("chunk_before_meta");
|
|
1442
|
+
queued += frame.payload.length;
|
|
1443
|
+
if (queued > total) throw new Error("too_many_bytes");
|
|
1444
|
+
pipeline = pipeline.then(async (sink) => {
|
|
1445
|
+
await sink.write(frame.payload);
|
|
1446
|
+
const hasher = await hasherPromise;
|
|
1447
|
+
hasher.update(frame.payload);
|
|
1448
|
+
got += frame.payload.length;
|
|
1449
|
+
s.diag.bytes = got;
|
|
1450
|
+
opts.onProgress?.(got, total);
|
|
1451
|
+
if (protocol >= 2 && (got >= nextCredit || got === total)) {
|
|
1452
|
+
dc?.send(encodeCredit(got));
|
|
1453
|
+
while (nextCredit <= got) nextCredit += DIRECT_RECEIVE_WINDOW_BYTES;
|
|
1454
|
+
}
|
|
1455
|
+
return sink;
|
|
1456
|
+
});
|
|
1457
|
+
pipeline.catch(() => s.fail("sink"));
|
|
1458
|
+
} else if (frame.type === "done") {
|
|
1459
|
+
if (!pipeline || finished) throw new Error("done_before_meta");
|
|
1460
|
+
finished = true;
|
|
1461
|
+
void pipeline.then(async (sink) => {
|
|
1462
|
+
if (got !== total || queued !== total) throw new Error("size_mismatch");
|
|
1463
|
+
const sha2563 = (await hasherPromise).digest();
|
|
1464
|
+
if (protocol >= 2 && (!frame.digest || frame.digest.size !== got || frame.digest.sha256 !== sha2563)) throw new Error("digest_mismatch");
|
|
1465
|
+
await sink.close();
|
|
1466
|
+
dc?.send(DONE_FRAME);
|
|
1467
|
+
s.setPhase("done");
|
|
1468
|
+
}).catch(() => s.fail("integrity"));
|
|
1469
|
+
} else if (frame.type === "abort") {
|
|
1470
|
+
s.fail("peer_abort");
|
|
1471
|
+
}
|
|
1472
|
+
} catch {
|
|
1473
|
+
s.fail("protocol");
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
dc.onclose = () => {
|
|
1477
|
+
if (!s.isTerminal()) s.fail("peer_closed");
|
|
1478
|
+
};
|
|
1479
|
+
};
|
|
1480
|
+
return {
|
|
1481
|
+
accept: (msg) => {
|
|
1482
|
+
if (s.isTerminal()) return;
|
|
1483
|
+
s.signaled();
|
|
1484
|
+
if (msg.kind === "offer" && (!pc.remoteDescription || pc.signalingState === "stable")) {
|
|
1485
|
+
const isRestart = !!pc.remoteDescription;
|
|
1486
|
+
const offerGeneration = msg.generation ?? (isRestart ? generation + 1 : 1);
|
|
1487
|
+
if (isRestart && msg.generation !== void 0 && offerGeneration <= generation) return;
|
|
1488
|
+
generation = offerGeneration;
|
|
1489
|
+
protocol = msg.protocol === 2 ? 2 : 1;
|
|
1490
|
+
plumbing.expectRemoteGeneration(generation);
|
|
1491
|
+
plumbing.setLocalGeneration(generation);
|
|
1492
|
+
void (async () => {
|
|
1493
|
+
if (isRestart && opts.refreshIce) {
|
|
1494
|
+
const iceServers = await opts.refreshIce();
|
|
1495
|
+
s.setIceServers(iceServers);
|
|
1496
|
+
}
|
|
1497
|
+
await pc.setRemoteDescription({ type: "offer", sdp: msg.sdp });
|
|
1498
|
+
plumbing.remoteDescriptionSet(generation);
|
|
1499
|
+
const answer = await pc.createAnswer();
|
|
1500
|
+
await pc.setLocalDescription(answer);
|
|
1501
|
+
await opts.send({ kind: "answer", sdp: answer.sdp, protocol: 2, generation });
|
|
1502
|
+
})().catch(() => s.fail("signaling"));
|
|
1503
|
+
}
|
|
1504
|
+
if (msg.kind === "ice") plumbing.applyIce(msg.candidate, msg.generation);
|
|
1505
|
+
},
|
|
1506
|
+
close: () => {
|
|
1507
|
+
if (!s.isTerminal() && dc?.readyState === "open") {
|
|
1508
|
+
try {
|
|
1509
|
+
dc.send(ABORT_FRAME);
|
|
1510
|
+
} catch {
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
sinkRef?.abort?.();
|
|
1514
|
+
s.cleanup();
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1360
1518
|
|
|
1361
1519
|
// src/shared/direct-fallback.ts
|
|
1362
1520
|
function createFallbackMeta(file) {
|
|
@@ -1396,6 +1554,15 @@ function partPlainBytes(meta, partNumber) {
|
|
|
1396
1554
|
if (partNumber < meta.part_count) return meta.part_bytes;
|
|
1397
1555
|
return meta.size - meta.part_bytes * (meta.part_count - 1);
|
|
1398
1556
|
}
|
|
1557
|
+
function fallbackCipherPartBytes(meta, partNumber) {
|
|
1558
|
+
return partPlainBytes(meta, partNumber) + meta.tag_bytes;
|
|
1559
|
+
}
|
|
1560
|
+
function fallbackCipherOffset(meta, partNumber) {
|
|
1561
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > meta.part_count) {
|
|
1562
|
+
throw new Error("bad_fallback_part");
|
|
1563
|
+
}
|
|
1564
|
+
return (partNumber - 1) * (meta.part_bytes + meta.tag_bytes);
|
|
1565
|
+
}
|
|
1399
1566
|
function ivFor(meta, partNumber) {
|
|
1400
1567
|
const iv = new Uint8Array(12);
|
|
1401
1568
|
iv.set(b64ToBytes(meta.nonce_b64), 0);
|
|
@@ -1431,6 +1598,21 @@ async function encryptFallbackPart(meta, offerId, partNumber, plain, key) {
|
|
|
1431
1598
|
);
|
|
1432
1599
|
return new Uint8Array(out);
|
|
1433
1600
|
}
|
|
1601
|
+
async function decryptFallbackPart(meta, offerId, partNumber, cipher, key) {
|
|
1602
|
+
const plainBytes = partPlainBytes(meta, partNumber);
|
|
1603
|
+
if (cipher.length !== plainBytes + meta.tag_bytes) throw new Error("bad_fallback_part_size");
|
|
1604
|
+
const out = await crypto.subtle.decrypt(
|
|
1605
|
+
{
|
|
1606
|
+
name: "AES-GCM",
|
|
1607
|
+
iv: ivFor(meta, partNumber),
|
|
1608
|
+
additionalData: aadFor(offerId, partNumber, plainBytes),
|
|
1609
|
+
tagLength: 128
|
|
1610
|
+
},
|
|
1611
|
+
key ?? await aesKey(meta),
|
|
1612
|
+
cipher
|
|
1613
|
+
);
|
|
1614
|
+
return new Uint8Array(out);
|
|
1615
|
+
}
|
|
1434
1616
|
var RETRIES = 3;
|
|
1435
1617
|
var URL_BATCH = 16;
|
|
1436
1618
|
var CONCURRENCY = 2;
|
|
@@ -1500,6 +1682,74 @@ async function uploadFallback(options) {
|
|
|
1500
1682
|
}
|
|
1501
1683
|
return completed.sort((a, b) => a.partNumber - b.partNumber);
|
|
1502
1684
|
}
|
|
1685
|
+
async function downloadFallback(options) {
|
|
1686
|
+
const { meta, offerId, sink, signal } = options;
|
|
1687
|
+
fallbackMetaOf(meta);
|
|
1688
|
+
const key = await aesKey(meta);
|
|
1689
|
+
const fetcher = options.fetcher ?? fetch;
|
|
1690
|
+
let partNumber = 1;
|
|
1691
|
+
let plainDone = 0;
|
|
1692
|
+
let attempts = 0;
|
|
1693
|
+
try {
|
|
1694
|
+
while (partNumber <= meta.part_count) {
|
|
1695
|
+
aborted(signal);
|
|
1696
|
+
const cipherOffset = fallbackCipherOffset(meta, partNumber);
|
|
1697
|
+
try {
|
|
1698
|
+
const url = await options.getUrl();
|
|
1699
|
+
const response = await fetcher(url, {
|
|
1700
|
+
headers: cipherOffset > 0 ? { Range: `bytes=${cipherOffset}-` } : void 0,
|
|
1701
|
+
signal
|
|
1702
|
+
});
|
|
1703
|
+
if (!response.ok || cipherOffset > 0 && response.status !== 206 || !response.body) {
|
|
1704
|
+
throw new Error(`fallback_get_${response.status}`);
|
|
1705
|
+
}
|
|
1706
|
+
const reader = response.body.getReader();
|
|
1707
|
+
const held = [];
|
|
1708
|
+
let heldBytes = 0;
|
|
1709
|
+
while (partNumber <= meta.part_count) {
|
|
1710
|
+
const needed = fallbackCipherPartBytes(meta, partNumber);
|
|
1711
|
+
while (heldBytes < needed) {
|
|
1712
|
+
const next = await reader.read();
|
|
1713
|
+
if (next.done) throw new Error("fallback_get_short");
|
|
1714
|
+
held.push(next.value);
|
|
1715
|
+
heldBytes += next.value.length;
|
|
1716
|
+
}
|
|
1717
|
+
const cipher = new Uint8Array(needed);
|
|
1718
|
+
let copied = 0;
|
|
1719
|
+
while (copied < needed) {
|
|
1720
|
+
const chunk = held.shift();
|
|
1721
|
+
const take = Math.min(chunk.length, needed - copied);
|
|
1722
|
+
cipher.set(chunk.subarray(0, take), copied);
|
|
1723
|
+
copied += take;
|
|
1724
|
+
heldBytes -= take;
|
|
1725
|
+
if (take < chunk.length) held.unshift(chunk.subarray(take));
|
|
1726
|
+
}
|
|
1727
|
+
const plain = await decryptFallbackPart(meta, offerId, partNumber, cipher, key);
|
|
1728
|
+
await sink.write(plain);
|
|
1729
|
+
plainDone += plain.length;
|
|
1730
|
+
partNumber++;
|
|
1731
|
+
attempts = 0;
|
|
1732
|
+
options.onProgress?.(plainDone, meta.size);
|
|
1733
|
+
}
|
|
1734
|
+
if (heldBytes > 0) throw new Error("fallback_get_long");
|
|
1735
|
+
} catch (error) {
|
|
1736
|
+
if (error.name === "AbortError") throw error;
|
|
1737
|
+
if (error.name === "OperationError" || error.message === "fallback_get_long") {
|
|
1738
|
+
throw error;
|
|
1739
|
+
}
|
|
1740
|
+
attempts++;
|
|
1741
|
+
if (attempts >= RETRIES) throw error;
|
|
1742
|
+
options.onRetry?.();
|
|
1743
|
+
await pause(250 * 2 ** (attempts - 1), signal);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
if (plainDone !== meta.size) throw new Error("fallback_plain_size");
|
|
1747
|
+
await sink.close();
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
sink.abort?.();
|
|
1750
|
+
throw error;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1503
1753
|
|
|
1504
1754
|
// src/shared/sharedchannel.ts
|
|
1505
1755
|
import { xchacha20poly1305 as xchacha20poly13052 } from "@noble/ciphers/chacha";
|
|
@@ -2206,7 +2456,7 @@ async function directGrantFor(ctx, channel) {
|
|
|
2206
2456
|
if (!grant.direct_mode) throw new ZasError("not_direct_mode", 409);
|
|
2207
2457
|
return grant;
|
|
2208
2458
|
}
|
|
2209
|
-
function
|
|
2459
|
+
function channelLabel(ctx, grant) {
|
|
2210
2460
|
try {
|
|
2211
2461
|
return channelNameOf(ctx.identity, grant);
|
|
2212
2462
|
} catch {
|
|
@@ -2224,7 +2474,7 @@ async function sendDirect(ctx, input, report, deps = {}) {
|
|
|
2224
2474
|
const sleep2 = deps.sleep ?? defaultSleep2;
|
|
2225
2475
|
const grant = await directGrantFor(ctx, input.channel);
|
|
2226
2476
|
const key = channelKeyOf(ctx.identity, grant);
|
|
2227
|
-
const channelName =
|
|
2477
|
+
const channelName = channelLabel(ctx, grant);
|
|
2228
2478
|
const file = await (deps.openFile ?? openAsBlob)(input.path).catch(() => {
|
|
2229
2479
|
throw new ZasError("upload_failed", 400);
|
|
2230
2480
|
});
|
|
@@ -2423,80 +2673,19 @@ async function sendDirectFallback(ctx, record, report, deps = {}) {
|
|
|
2423
2673
|
};
|
|
2424
2674
|
}
|
|
2425
2675
|
|
|
2426
|
-
// src/
|
|
2427
|
-
import {
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
this.now = opts.now ?? (() => Date.now());
|
|
2440
|
-
this.waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
|
|
2441
|
-
}
|
|
2442
|
-
start(kind, title, channel, work) {
|
|
2443
|
-
const job = {
|
|
2444
|
-
id: randomUUID(),
|
|
2445
|
-
kind,
|
|
2446
|
-
title,
|
|
2447
|
-
channel,
|
|
2448
|
-
started_at: this.now(),
|
|
2449
|
-
phase: null,
|
|
2450
|
-
status: "running"
|
|
2451
|
-
};
|
|
2452
|
-
this.jobs.unshift(job);
|
|
2453
|
-
this.jobs.length = Math.min(this.jobs.length, HISTORY);
|
|
2454
|
-
const report = (phase) => {
|
|
2455
|
-
if (job.status === "running") job.phase = phase;
|
|
2456
|
-
};
|
|
2457
|
-
this.settled.set(job, work(report).then(
|
|
2458
|
-
(result) => {
|
|
2459
|
-
job.status = "done";
|
|
2460
|
-
job.result = result;
|
|
2461
|
-
return job;
|
|
2462
|
-
},
|
|
2463
|
-
(error) => {
|
|
2464
|
-
job.status = "failed";
|
|
2465
|
-
job.error = error instanceof ZasError ? {
|
|
2466
|
-
code: error.code,
|
|
2467
|
-
status: error.status,
|
|
2468
|
-
sentence: humanSentence(error),
|
|
2469
|
-
message: error.message,
|
|
2470
|
-
...error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {},
|
|
2471
|
-
...error.serverCode !== void 0 ? { serverCode: error.serverCode } : {}
|
|
2472
|
-
} : { code: "upload_failed", status: 0, sentence: humanSentence(new ZasError("upload_failed", 0)) };
|
|
2473
|
-
return job;
|
|
2474
|
-
}
|
|
2475
|
-
));
|
|
2476
|
-
return job;
|
|
2477
|
-
}
|
|
2478
|
-
/** Resolves when the work settles, or when the wait runs out — the same job
|
|
2479
|
-
* object either way, so the caller reads `status` rather than guessing. */
|
|
2480
|
-
async wait(job) {
|
|
2481
|
-
const settled = this.settled.get(job);
|
|
2482
|
-
if (!settled) return job;
|
|
2483
|
-
let timer;
|
|
2484
|
-
const deadline = new Promise((resolve2) => {
|
|
2485
|
-
timer = setTimeout(() => resolve2(job), this.waitMs);
|
|
2486
|
-
});
|
|
2487
|
-
try {
|
|
2488
|
-
return await Promise.race([settled, deadline]);
|
|
2489
|
-
} finally {
|
|
2490
|
-
clearTimeout(timer);
|
|
2491
|
-
}
|
|
2492
|
-
}
|
|
2493
|
-
list() {
|
|
2494
|
-
return this.jobs.slice(0, HISTORY);
|
|
2495
|
-
}
|
|
2496
|
-
get(id) {
|
|
2497
|
-
return this.jobs.find((job) => job.id === id);
|
|
2498
|
-
}
|
|
2499
|
-
};
|
|
2676
|
+
// src/receive.ts
|
|
2677
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
2678
|
+
import {
|
|
2679
|
+
closeSync as closeSync2,
|
|
2680
|
+
existsSync as existsSync3,
|
|
2681
|
+
mkdirSync as mkdirSync3,
|
|
2682
|
+
openSync as openSync2,
|
|
2683
|
+
renameSync as renameSync3,
|
|
2684
|
+
rmdirSync as rmdirSync2,
|
|
2685
|
+
unlinkSync as unlinkSync2,
|
|
2686
|
+
writeSync as writeSync2
|
|
2687
|
+
} from "node:fs";
|
|
2688
|
+
import { dirname as dirname2 } from "node:path";
|
|
2500
2689
|
|
|
2501
2690
|
// src/read.ts
|
|
2502
2691
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
@@ -2753,9 +2942,392 @@ async function getItem(ctx, channel, id, dest) {
|
|
|
2753
2942
|
return { path: target, bytes: written };
|
|
2754
2943
|
}
|
|
2755
2944
|
|
|
2945
|
+
// src/receive.ts
|
|
2946
|
+
var OFFER_WAIT_MS2 = 9.5 * 60 * 1e3;
|
|
2947
|
+
var OFFER_POLL_MS2 = 1e3;
|
|
2948
|
+
var SIGNAL_POLL_MS2 = 400;
|
|
2949
|
+
var OFFER_PAGE = 10;
|
|
2950
|
+
var OPEN_OFFERS = {
|
|
2951
|
+
from: [{ collectionId: "direct" }],
|
|
2952
|
+
orderBy: [{ field: { fieldPath: "created_at" }, direction: "DESCENDING" }],
|
|
2953
|
+
limit: OFFER_PAGE
|
|
2954
|
+
};
|
|
2955
|
+
var SIGNALS_FOR_RECEIVER = {
|
|
2956
|
+
from: [{ collectionId: "signals" }],
|
|
2957
|
+
where: { fieldFilter: { field: { fieldPath: "for" }, op: "EQUAL", value: { stringValue: "receiver" } } }
|
|
2958
|
+
};
|
|
2959
|
+
var stringField2 = (doc, key) => doc?.fields?.[key]?.stringValue;
|
|
2960
|
+
var idOf = (doc) => (doc.name ?? "").split("/").pop() ?? "";
|
|
2961
|
+
async function receiveGrantFor(ctx, channel) {
|
|
2962
|
+
const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
|
|
2963
|
+
if (!grant.read) throw new ZasError("read_forbidden", 403);
|
|
2964
|
+
if (!grant.direct_mode) throw new ZasError("not_direct_mode", 409);
|
|
2965
|
+
return grant;
|
|
2966
|
+
}
|
|
2967
|
+
var DiskSink = class {
|
|
2968
|
+
constructor(dest, fallbackName) {
|
|
2969
|
+
this.dest = dest;
|
|
2970
|
+
this.fallbackName = fallbackName;
|
|
2971
|
+
}
|
|
2972
|
+
/** Where the bytes will be once `close` has run. */
|
|
2973
|
+
target;
|
|
2974
|
+
written = 0;
|
|
2975
|
+
/** A filesystem refusal, kept so the run can answer `write_failed` rather
|
|
2976
|
+
* than reporting the engine's generic sink failure. */
|
|
2977
|
+
failure;
|
|
2978
|
+
created;
|
|
2979
|
+
tmp;
|
|
2980
|
+
fd;
|
|
2981
|
+
open(meta) {
|
|
2982
|
+
try {
|
|
2983
|
+
const chosen = destinationOf(this.dest, meta.name, this.fallbackName);
|
|
2984
|
+
this.target = chosen.target;
|
|
2985
|
+
this.created = chosen.created;
|
|
2986
|
+
mkdirSync3(dirname2(this.target), { recursive: true, mode: 448 });
|
|
2987
|
+
this.tmp = `${this.target}.${randomBytes3(6).toString("hex")}.tmp`;
|
|
2988
|
+
this.fd = openSync2(this.tmp, "wx", 384);
|
|
2989
|
+
} catch (error) {
|
|
2990
|
+
throw this.remember(error);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
write(bytes) {
|
|
2994
|
+
try {
|
|
2995
|
+
if (this.fd === void 0) throw new Error("sink_closed");
|
|
2996
|
+
this.written += writeSync2(this.fd, bytes);
|
|
2997
|
+
} catch (error) {
|
|
2998
|
+
throw this.remember(error);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
close() {
|
|
3002
|
+
try {
|
|
3003
|
+
if (this.fd === void 0) return;
|
|
3004
|
+
closeSync2(this.fd);
|
|
3005
|
+
this.fd = void 0;
|
|
3006
|
+
renameSync3(this.tmp, this.target);
|
|
3007
|
+
this.tmp = void 0;
|
|
3008
|
+
} catch (error) {
|
|
3009
|
+
throw this.remember(error);
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
/** Best effort, and safe to call twice: the engine aborts on every failure
|
|
3013
|
+
* path of its own, and the job aborts again on the way out. */
|
|
3014
|
+
abort() {
|
|
3015
|
+
if (this.fd !== void 0) {
|
|
3016
|
+
try {
|
|
3017
|
+
closeSync2(this.fd);
|
|
3018
|
+
} catch {
|
|
3019
|
+
}
|
|
3020
|
+
this.fd = void 0;
|
|
3021
|
+
}
|
|
3022
|
+
if (this.tmp !== void 0) {
|
|
3023
|
+
if (existsSync3(this.tmp)) try {
|
|
3024
|
+
unlinkSync2(this.tmp);
|
|
3025
|
+
} catch {
|
|
3026
|
+
}
|
|
3027
|
+
this.tmp = void 0;
|
|
3028
|
+
}
|
|
3029
|
+
if (this.created !== void 0) {
|
|
3030
|
+
try {
|
|
3031
|
+
rmdirSync2(this.created);
|
|
3032
|
+
} catch {
|
|
3033
|
+
}
|
|
3034
|
+
this.created = void 0;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
/** A `ZasError` says what happened already. An errno does not, so it is kept
|
|
3038
|
+
* and turned into one sentence by the caller — recognised by the shape Node
|
|
3039
|
+
* puts on it (`syscall`/`errno`) rather than by `code`, which `ZasError`
|
|
3040
|
+
* also carries. */
|
|
3041
|
+
remember(error) {
|
|
3042
|
+
const failure = error;
|
|
3043
|
+
if (typeof failure?.syscall === "string" || typeof failure?.errno === "number") this.failure = failure;
|
|
3044
|
+
return error;
|
|
3045
|
+
}
|
|
3046
|
+
};
|
|
3047
|
+
async function receiveDirect(ctx, input, report, deps = {}) {
|
|
3048
|
+
const now = deps.now ?? (() => Date.now());
|
|
3049
|
+
const sleep2 = deps.sleep ?? defaultSleep2;
|
|
3050
|
+
const grant = await receiveGrantFor(ctx, input.channel);
|
|
3051
|
+
const key = channelKeyOf(ctx.identity, grant);
|
|
3052
|
+
const channelName = channelLabel(ctx, grant);
|
|
3053
|
+
const cid = grant.channel_id;
|
|
3054
|
+
const owner = ctx.identity.owner_uid;
|
|
3055
|
+
const device = deps.device ?? newDeviceToken();
|
|
3056
|
+
const { seal, open } = sealer(key, grant.key_version);
|
|
3057
|
+
const stamp = { device, owner_uid: owner };
|
|
3058
|
+
const startedAt = now();
|
|
3059
|
+
const channelPath = `accounts/${owner}/channels/${cid}`;
|
|
3060
|
+
report("waiting");
|
|
3061
|
+
const waitUntil = startedAt + (deps.offerWaitMs ?? OFFER_WAIT_MS2);
|
|
3062
|
+
let id;
|
|
3063
|
+
let meta;
|
|
3064
|
+
while (id === void 0) {
|
|
3065
|
+
const rows = await ctx.client.firestoreRunQuery(channelPath, OPEN_OFFERS).catch(() => []);
|
|
3066
|
+
for (const row of rows) {
|
|
3067
|
+
if (stringField2(row, "state") !== "open") continue;
|
|
3068
|
+
if (stringField2(row, "sender") === ctx.identity.agent_uid) continue;
|
|
3069
|
+
const enc = stringField2(row, "meta_enc");
|
|
3070
|
+
if (enc === void 0) continue;
|
|
3071
|
+
let candidate;
|
|
3072
|
+
try {
|
|
3073
|
+
candidate = open(enc);
|
|
3074
|
+
} catch {
|
|
3075
|
+
continue;
|
|
3076
|
+
}
|
|
3077
|
+
if (typeof candidate?.name !== "string" || typeof candidate?.size !== "number") continue;
|
|
3078
|
+
if (candidate.size > DIRECT_FILE_MAX_BYTES) throw new ZasError("file_too_big", 413);
|
|
3079
|
+
const offerId2 = idOf(row);
|
|
3080
|
+
if (offerId2 === "") continue;
|
|
3081
|
+
try {
|
|
3082
|
+
await ctx.client.api("POST", `/direct/${cid}/${offerId2}/claim`, { size_bytes: candidate.size, ...stamp });
|
|
3083
|
+
} catch (error) {
|
|
3084
|
+
const word = error instanceof ZasError ? error.serverCode ?? error.code : "";
|
|
3085
|
+
if (word === "claimed") throw new ZasError("offer_taken", 409);
|
|
3086
|
+
if (word === "expired" || word === "own_offer" || word === "not_found") continue;
|
|
3087
|
+
throw error;
|
|
3088
|
+
}
|
|
3089
|
+
id = offerId2;
|
|
3090
|
+
meta = candidate;
|
|
3091
|
+
break;
|
|
3092
|
+
}
|
|
3093
|
+
if (id !== void 0) break;
|
|
3094
|
+
if (now() >= waitUntil) throw new ZasError("no_offer", 0);
|
|
3095
|
+
await sleep2(deps.offerPollMs ?? OFFER_POLL_MS2);
|
|
3096
|
+
}
|
|
3097
|
+
const offered = meta;
|
|
3098
|
+
const offerId = id;
|
|
3099
|
+
const route = `/direct/${cid}/${offerId}`;
|
|
3100
|
+
const offerPath = `${channelPath}/direct/${offerId}`;
|
|
3101
|
+
const setState = (state) => ctx.client.api("POST", `${route}/state`, { state, ...stamp }).then(() => void 0, () => void 0);
|
|
3102
|
+
await (deps.installWebRtc ?? installWebRtc)();
|
|
3103
|
+
const fetchIce = async () => {
|
|
3104
|
+
try {
|
|
3105
|
+
const r = await ctx.client.api("POST", `${route}/ice`, stamp);
|
|
3106
|
+
return Array.isArray(r.ice) ? r.ice : [];
|
|
3107
|
+
} catch {
|
|
3108
|
+
return [];
|
|
3109
|
+
}
|
|
3110
|
+
};
|
|
3111
|
+
const turn = await fetchIce();
|
|
3112
|
+
report("connecting");
|
|
3113
|
+
const sink = new DiskSink(input.dest, offerId);
|
|
3114
|
+
let resolveOutcome;
|
|
3115
|
+
const outcome = new Promise((resolve2) => {
|
|
3116
|
+
resolveOutcome = resolve2;
|
|
3117
|
+
});
|
|
3118
|
+
let diag;
|
|
3119
|
+
let via;
|
|
3120
|
+
const handle = (deps.engine ?? startReceiver)({
|
|
3121
|
+
ice: [...DIRECT_ICE, ...turn],
|
|
3122
|
+
refreshIce: async () => [...DIRECT_ICE, ...await fetchIce()],
|
|
3123
|
+
// The name and size this end read off the offer. The engine refuses a
|
|
3124
|
+
// sender that announces anything else.
|
|
3125
|
+
expectedMeta: offered,
|
|
3126
|
+
send: (msg) => ctx.client.api("POST", `${route}/signal`, { payload_enc: seal(msg), for: "sender", ...stamp }).then(() => void 0),
|
|
3127
|
+
onPhase: (phase2) => {
|
|
3128
|
+
if (phase2 === "flight") report("flight");
|
|
3129
|
+
if (phase2 === "done" || phase2 === "failed") resolveOutcome(phase2);
|
|
3130
|
+
},
|
|
3131
|
+
onPath: (p) => {
|
|
3132
|
+
via = p;
|
|
3133
|
+
},
|
|
3134
|
+
onDiag: (d) => {
|
|
3135
|
+
diag = d;
|
|
3136
|
+
},
|
|
3137
|
+
sink: async (m) => {
|
|
3138
|
+
sink.open(m);
|
|
3139
|
+
return sink;
|
|
3140
|
+
}
|
|
3141
|
+
});
|
|
3142
|
+
let over = false;
|
|
3143
|
+
const pump = (async () => {
|
|
3144
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3145
|
+
while (!over) {
|
|
3146
|
+
let rows = [];
|
|
3147
|
+
try {
|
|
3148
|
+
rows = await ctx.client.firestoreRunQuery(offerPath, SIGNALS_FOR_RECEIVER);
|
|
3149
|
+
} catch {
|
|
3150
|
+
}
|
|
3151
|
+
for (const row of rows) {
|
|
3152
|
+
if (!row?.name || seen.has(row.name)) continue;
|
|
3153
|
+
seen.add(row.name);
|
|
3154
|
+
const enc = stringField2(row, "payload_enc");
|
|
3155
|
+
if (!enc) continue;
|
|
3156
|
+
try {
|
|
3157
|
+
handle.accept(open(enc));
|
|
3158
|
+
} catch {
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
if (!over) await Promise.race([outcome, sleep2(deps.signalPollMs ?? SIGNAL_POLL_MS2)]);
|
|
3162
|
+
}
|
|
3163
|
+
})();
|
|
3164
|
+
const phase = await outcome;
|
|
3165
|
+
over = true;
|
|
3166
|
+
await pump;
|
|
3167
|
+
report("finishing");
|
|
3168
|
+
if (phase === "done") {
|
|
3169
|
+
await setState("done");
|
|
3170
|
+
return {
|
|
3171
|
+
offer_id: offerId,
|
|
3172
|
+
channel_id: cid,
|
|
3173
|
+
channel_name: channelName,
|
|
3174
|
+
name: offered.name,
|
|
3175
|
+
bytes: sink.written,
|
|
3176
|
+
path: sink.target,
|
|
3177
|
+
...via ? { via } : {},
|
|
3178
|
+
duration_ms: now() - startedAt
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
sink.abort();
|
|
3182
|
+
const doc = await ctx.client.firestoreGet(offerPath).catch(() => null);
|
|
3183
|
+
const withdrawn = doc === null || stringField2(doc, "state") === "cancelled";
|
|
3184
|
+
await setState("failed");
|
|
3185
|
+
if (sink.failure) throw new ZasError("write_failed", 0, sink.failure.message);
|
|
3186
|
+
if (withdrawn) throw new ZasError("direct_cancelled", 0);
|
|
3187
|
+
const reason = diag?.reason || "unknown";
|
|
3188
|
+
deps.onFailed?.({
|
|
3189
|
+
channel_id: cid,
|
|
3190
|
+
channel_name: channelName,
|
|
3191
|
+
offer_id: offerId,
|
|
3192
|
+
owner_uid: owner,
|
|
3193
|
+
device,
|
|
3194
|
+
...input.dest !== void 0 ? { dest: input.dest } : {},
|
|
3195
|
+
meta: offered,
|
|
3196
|
+
key,
|
|
3197
|
+
key_version: grant.key_version,
|
|
3198
|
+
reason
|
|
3199
|
+
});
|
|
3200
|
+
throw new ZasError("direct_failed", 0, reason);
|
|
3201
|
+
}
|
|
3202
|
+
async function receiveDirectFallback(ctx, record, report, deps = {}) {
|
|
3203
|
+
const now = deps.now ?? (() => Date.now());
|
|
3204
|
+
const startedAt = now();
|
|
3205
|
+
const { open } = sealer(record.key, record.key_version);
|
|
3206
|
+
const stamp = { device: record.device, owner_uid: record.owner_uid };
|
|
3207
|
+
const base = `/direct/${record.channel_id}/${record.offer_id}/fallback`;
|
|
3208
|
+
const offerPath = `accounts/${record.owner_uid}/channels/${record.channel_id}/direct/${record.offer_id}`;
|
|
3209
|
+
const doc = await ctx.client.firestoreGet(offerPath);
|
|
3210
|
+
const enc = stringField2(doc, "fallback_meta_enc");
|
|
3211
|
+
let meta;
|
|
3212
|
+
if (enc !== void 0) {
|
|
3213
|
+
try {
|
|
3214
|
+
meta = fallbackMetaOf(open(enc));
|
|
3215
|
+
} catch {
|
|
3216
|
+
meta = void 0;
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
if (!meta) throw new ZasError("fallback_unavailable", 409);
|
|
3220
|
+
if (meta.name !== record.meta.name || meta.size !== record.meta.size || meta.mime !== record.meta.mime) {
|
|
3221
|
+
throw new ZasError("file_changed", 0);
|
|
3222
|
+
}
|
|
3223
|
+
const sink = new DiskSink(record.dest, record.offer_id);
|
|
3224
|
+
report("downloading");
|
|
3225
|
+
try {
|
|
3226
|
+
sink.open(meta);
|
|
3227
|
+
await downloadFallback({
|
|
3228
|
+
offerId: record.offer_id,
|
|
3229
|
+
meta,
|
|
3230
|
+
sink,
|
|
3231
|
+
getUrl: async () => (await ctx.client.api("POST", `${base}/download`, stamp)).url,
|
|
3232
|
+
...deps.fetcher ? { fetcher: deps.fetcher } : {}
|
|
3233
|
+
});
|
|
3234
|
+
} catch (error) {
|
|
3235
|
+
sink.abort();
|
|
3236
|
+
if (sink.failure) throw new ZasError("write_failed", 0, sink.failure.message);
|
|
3237
|
+
if (error instanceof ZasError) throw error;
|
|
3238
|
+
throw new ZasError("network", 0, error instanceof Error ? error.message : String(error));
|
|
3239
|
+
}
|
|
3240
|
+
report("finishing");
|
|
3241
|
+
await ctx.client.api("POST", `${base}/received`, stamp);
|
|
3242
|
+
return {
|
|
3243
|
+
offer_id: record.offer_id,
|
|
3244
|
+
channel_id: record.channel_id,
|
|
3245
|
+
channel_name: record.channel_name,
|
|
3246
|
+
name: meta.name,
|
|
3247
|
+
bytes: sink.written,
|
|
3248
|
+
path: sink.target,
|
|
3249
|
+
duration_ms: now() - startedAt
|
|
3250
|
+
};
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
// src/jobs.ts
|
|
3254
|
+
import { randomUUID } from "node:crypto";
|
|
3255
|
+
var DEFAULT_WAIT_MS = 6e4;
|
|
3256
|
+
var HISTORY = 50;
|
|
3257
|
+
var JobRunner = class {
|
|
3258
|
+
now;
|
|
3259
|
+
waitMs;
|
|
3260
|
+
/** Newest first, and trimmed. */
|
|
3261
|
+
jobs = [];
|
|
3262
|
+
/** Keyed on the job object, not its id, so a caller holding a job that has
|
|
3263
|
+
* already aged out of the list can still wait for it to finish. */
|
|
3264
|
+
settled = /* @__PURE__ */ new WeakMap();
|
|
3265
|
+
constructor(opts = {}) {
|
|
3266
|
+
this.now = opts.now ?? (() => Date.now());
|
|
3267
|
+
this.waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
|
|
3268
|
+
}
|
|
3269
|
+
start(kind, title, channel, work) {
|
|
3270
|
+
const job = {
|
|
3271
|
+
id: randomUUID(),
|
|
3272
|
+
kind,
|
|
3273
|
+
title,
|
|
3274
|
+
channel,
|
|
3275
|
+
started_at: this.now(),
|
|
3276
|
+
phase: null,
|
|
3277
|
+
status: "running"
|
|
3278
|
+
};
|
|
3279
|
+
this.jobs.unshift(job);
|
|
3280
|
+
this.jobs.length = Math.min(this.jobs.length, HISTORY);
|
|
3281
|
+
const report = (phase) => {
|
|
3282
|
+
if (job.status === "running") job.phase = phase;
|
|
3283
|
+
};
|
|
3284
|
+
this.settled.set(job, work(report).then(
|
|
3285
|
+
(result) => {
|
|
3286
|
+
job.status = "done";
|
|
3287
|
+
job.result = result;
|
|
3288
|
+
return job;
|
|
3289
|
+
},
|
|
3290
|
+
(error) => {
|
|
3291
|
+
job.status = "failed";
|
|
3292
|
+
job.error = error instanceof ZasError ? {
|
|
3293
|
+
code: error.code,
|
|
3294
|
+
status: error.status,
|
|
3295
|
+
sentence: humanSentence(error),
|
|
3296
|
+
message: error.message,
|
|
3297
|
+
...error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {},
|
|
3298
|
+
...error.serverCode !== void 0 ? { serverCode: error.serverCode } : {}
|
|
3299
|
+
} : { code: "upload_failed", status: 0, sentence: humanSentence(new ZasError("upload_failed", 0)) };
|
|
3300
|
+
return job;
|
|
3301
|
+
}
|
|
3302
|
+
));
|
|
3303
|
+
return job;
|
|
3304
|
+
}
|
|
3305
|
+
/** Resolves when the work settles, or when the wait runs out — the same job
|
|
3306
|
+
* object either way, so the caller reads `status` rather than guessing. */
|
|
3307
|
+
async wait(job) {
|
|
3308
|
+
const settled = this.settled.get(job);
|
|
3309
|
+
if (!settled) return job;
|
|
3310
|
+
let timer;
|
|
3311
|
+
const deadline = new Promise((resolve2) => {
|
|
3312
|
+
timer = setTimeout(() => resolve2(job), this.waitMs);
|
|
3313
|
+
});
|
|
3314
|
+
try {
|
|
3315
|
+
return await Promise.race([settled, deadline]);
|
|
3316
|
+
} finally {
|
|
3317
|
+
clearTimeout(timer);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
list() {
|
|
3321
|
+
return this.jobs.slice(0, HISTORY);
|
|
3322
|
+
}
|
|
3323
|
+
get(id) {
|
|
3324
|
+
return this.jobs.find((job) => job.id === id);
|
|
3325
|
+
}
|
|
3326
|
+
};
|
|
3327
|
+
|
|
2756
3328
|
// src/server.ts
|
|
2757
3329
|
function agentVersion() {
|
|
2758
|
-
return true ? "0.
|
|
3330
|
+
return true ? "0.5.0" : "0.0.0-dev";
|
|
2759
3331
|
}
|
|
2760
3332
|
var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
|
|
2761
3333
|
var PAIR_ANNOUNCE_MS = 15e3;
|
|
@@ -2766,7 +3338,7 @@ var delay = (ms) => new Promise((resolve2) => {
|
|
|
2766
3338
|
function rightsOf(grant) {
|
|
2767
3339
|
const rights = [];
|
|
2768
3340
|
if (grant.send && grant.mode !== "view") rights.push(grant.direct_mode ? "send (Directo)" : "send");
|
|
2769
|
-
if (grant.read) rights.push("read");
|
|
3341
|
+
if (grant.read) rights.push(grant.direct_mode ? "receive (Directo)" : "read");
|
|
2770
3342
|
return rights.length > 0 ? rights.join(" \xB7 ") : "no access";
|
|
2771
3343
|
}
|
|
2772
3344
|
function buildServer(profile, deps = {}) {
|
|
@@ -2972,6 +3544,58 @@ function buildServer(profile, deps = {}) {
|
|
|
2972
3544
|
return failed(e);
|
|
2973
3545
|
}
|
|
2974
3546
|
});
|
|
3547
|
+
const failedReceives = /* @__PURE__ */ new Map();
|
|
3548
|
+
const rememberFailedReceive = (jobId, record) => {
|
|
3549
|
+
failedReceives.set(jobId, record);
|
|
3550
|
+
if (failedReceives.size > 50) failedReceives.delete(failedReceives.keys().next().value);
|
|
3551
|
+
};
|
|
3552
|
+
server.registerTool("zas_receive_direct", {
|
|
3553
|
+
description: "Receive a file the owner sends through Directo, straight onto this machine. Call it when the owner says they are sending you something: it waits for the offer, takes it, and writes the file to disk. Nothing is stored anywhere. Only for a channel in Directo mode, and only with a grant that includes reading. The call waits a minute and then returns a job id to check with zas_jobs; the wait for an offer alone can take ten minutes. Returns the path written; it never overwrites an existing file." + AGENT_CUE,
|
|
3554
|
+
inputSchema: {
|
|
3555
|
+
channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel."),
|
|
3556
|
+
dest: z.string().optional().describe('Where to write the file. A directory means "inside it". Defaults to a fresh temporary directory.')
|
|
3557
|
+
}
|
|
3558
|
+
}, async (input) => {
|
|
3559
|
+
try {
|
|
3560
|
+
const c = ctx();
|
|
3561
|
+
let job;
|
|
3562
|
+
job = runner.start(
|
|
3563
|
+
"receive",
|
|
3564
|
+
input.dest ?? "Directo",
|
|
3565
|
+
input.channel ?? "",
|
|
3566
|
+
(report) => receiveDirect(c, input, report, {
|
|
3567
|
+
...deps.receive ?? {},
|
|
3568
|
+
onFailed: (record) => rememberFailedReceive(job.id, record)
|
|
3569
|
+
})
|
|
3570
|
+
);
|
|
3571
|
+
return settled(await runner.wait(job));
|
|
3572
|
+
} catch (e) {
|
|
3573
|
+
return failed(e);
|
|
3574
|
+
}
|
|
3575
|
+
});
|
|
3576
|
+
server.registerTool("zas_receive_direct_fallback", {
|
|
3577
|
+
description: "After a zas_receive_direct job failed in flight, download the encrypted copy the sender chose to store instead. It works only if the person who was sending picked reliable delivery for that transfer. The file is decrypted on this machine and written to the same destination. Pass the failed job\u2019s id." + AGENT_CUE,
|
|
3578
|
+
inputSchema: {
|
|
3579
|
+
job: z.string().describe("The job id zas_receive_direct or zas_jobs reported for the receive that failed.")
|
|
3580
|
+
}
|
|
3581
|
+
}, async (input) => {
|
|
3582
|
+
try {
|
|
3583
|
+
const c = ctx();
|
|
3584
|
+
const record = failedReceives.get(input.job);
|
|
3585
|
+
if (!record) return failed(new ZasError("direct_not_failed", 0));
|
|
3586
|
+
const job = runner.start(
|
|
3587
|
+
"fallback",
|
|
3588
|
+
record.meta.name,
|
|
3589
|
+
record.channel_name,
|
|
3590
|
+
(report) => receiveDirectFallback(c, record, report, deps.receive)
|
|
3591
|
+
);
|
|
3592
|
+
const outcome = await runner.wait(job);
|
|
3593
|
+
if (outcome.status === "done") failedReceives.delete(input.job);
|
|
3594
|
+
return settled(outcome);
|
|
3595
|
+
} catch (e) {
|
|
3596
|
+
return failed(e);
|
|
3597
|
+
}
|
|
3598
|
+
});
|
|
2975
3599
|
server.registerTool("zas_send_note", {
|
|
2976
3600
|
description: "Send a note \u2014 plain text, or a code snippet with its language \u2014 into one of the owner's Zas channels." + AGENT_CUE,
|
|
2977
3601
|
inputSchema: {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zas-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server that lets a coding agent send files and notes into your Zas channels, and files through Directo.",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "MCP server that lets a coding agent send files and notes into your Zas channels, and send or receive files live through Directo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Sebastian Fernandez Quezada",
|