@twinsearth/w2m-dsh-plugin 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * DSH: Windows2MacO - the DSH Cordis plugin.
3
3
  *
4
4
  * # What this exposes
@@ -94,6 +94,7 @@ import { resolveChannels } from './update-channels.mjs';
94
94
  import { fetchLatestRelease, fetchPrereleaseRelease, requireChecksum, downloadVerified, DEFAULT_REPO } from './update-source.mjs';
95
95
  import { installUpdate } from './update-install.mjs';import { createHash, randomUUID } from 'node:crypto';
96
96
  import { promises as fs } from 'node:fs';
97
+ import { join } from 'node:path';
97
98
  import os from 'node:os';
98
99
  import path from 'node:path';
99
100
  // The individual memory layer (v0.9.6). Read through the agent's own module rather than re-parsing the
@@ -186,8 +187,51 @@ import { registerChatRoutes } from './fleet-chat-route.mjs';
186
187
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
187
188
  import { renderMemoryView } from './memory-view.mjs';
188
189
  import { buildBoard } from './task-graph.mjs';
190
+ import { verifyEnvelopeSignature } from '../util/envelope-signature.mjs';
189
191
  import { createAskStore } from './ask-store.mjs';
190
192
  import { deriveTurns } from './turn.mjs';
193
+ import { decryptChatMessage, deriveChatKey, encryptChatText, isEncrypted } from '../util/chat-crypto.mjs';
194
+
195
+ /**
196
+ * v1.2.6 reliability: two small files beside the plugin's state.
197
+ *
198
+ * `chat-cursor.json` remembers how far each conversation has been read, so "list" continues where the last page ended instead of
199
+ * starting over. `chat-spool.jsonl` holds messages that could not be sent because the Rabbit was unreachable; each carries the
200
+ * idempotency key it will be posted with, so a replay that the relay already received does not post the sentence twice.
201
+ */
202
+ const CHAT_CURSOR_FILE = 'chat-cursor.json';
203
+ const CHAT_SPOOL_FILE = 'chat-spool.jsonl';
204
+
205
+ async function readChatCursors(stateDir) {
206
+ if (typeof stateDir !== 'string' || stateDir === '') return {};
207
+ try {
208
+ const parsed = JSON.parse(await fs.readFile(join(stateDir, CHAT_CURSOR_FILE), 'utf8'));
209
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
210
+ } catch { return {}; }
211
+ }
212
+
213
+ async function writeChatCursors(stateDir, cursors) {
214
+ if (typeof stateDir !== 'string' || stateDir === '') return false;
215
+ try { await fs.writeFile(join(stateDir, CHAT_CURSOR_FILE), JSON.stringify(cursors, null, 2), 'utf8'); return true; }
216
+ catch { return false; }
217
+ }
218
+
219
+ async function readChatSpool(stateDir) {
220
+ if (typeof stateDir !== 'string' || stateDir === '') return [];
221
+ try {
222
+ const text = await fs.readFile(join(stateDir, CHAT_SPOOL_FILE), 'utf8');
223
+ return text.split('\n').map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter((entry) => entry !== null && typeof entry === 'object');
224
+ } catch { return []; }
225
+ }
226
+
227
+ async function writeChatSpool(stateDir, entries) {
228
+ if (typeof stateDir !== 'string' || stateDir === '') return false;
229
+ try {
230
+ await fs.writeFile(join(stateDir, CHAT_SPOOL_FILE), entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length === 0 ? '' : '\n'), 'utf8');
231
+ return true;
232
+ } catch { return false; }
233
+ }
234
+
191
235
  import { buildReducePlan } from '../util/dataflow.mjs';
192
236
  import { schemaProblems, strayParameterProblems } from './schema-spec.mjs';
193
237
  import { listIntents, resolveIntent } from './intents.mjs';
@@ -228,7 +272,7 @@ const MAX_STAGES = 16;
228
272
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
229
273
  * overrides it at runtime for tests and packagers.
230
274
  */
231
- const PLUGIN_VERSION = '1.2.5';
275
+ const PLUGIN_VERSION = '1.2.7';
232
276
 
233
277
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
234
278
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3727,6 +3771,15 @@ export async function apply(ctx, config = {}) {
3727
3771
  items: { type: 'string' },
3728
3772
  description: 'Which terminal conditions justify another attempt: expired, failed, partial, timeout. Omit to never retry.',
3729
3773
  },
3774
+ depends_on: {
3775
+ type: 'array',
3776
+ items: { type: 'string' },
3777
+ description: 'Task ids this task waits for. The relay will not dispatch it until every dependency has settled; then it is offered automatically.',
3778
+ },
3779
+ assignee: {
3780
+ type: 'string',
3781
+ description: 'Machine id that must execute this task. An assignee is a restriction: no other machine is offered the work, and an unknown id is refused by name.',
3782
+ },
3730
3783
  },
3731
3784
  output: jsonOutput,
3732
3785
  async execute(args, exec) {
@@ -4137,6 +4190,11 @@ export async function apply(ctx, config = {}) {
4137
4190
  // v1.2.4: the retry policy travels only when the caller asked for one, so a default dispatch stays byte-identical.
4138
4191
  ...(Number.isInteger(args?.max_attempts) && args.max_attempts > 1 ? { max_attempts: args.max_attempts } : {}),
4139
4192
  ...(Array.isArray(args?.retry_on) && args.retry_on.length > 0 ? { retry_on: args.retry_on } : {}),
4193
+ // v1.2.7: graph edges and assignment travel only when declared, so a default dispatch stays byte-identical.
4194
+ ...(Array.isArray(args?.depends_on) && args.depends_on.filter((id) => typeof id === 'string' && id !== '').length > 0
4195
+ ? { depends_on: args.depends_on.filter((id) => typeof id === 'string' && id !== '') }
4196
+ : {}),
4197
+ ...(typeof args?.assignee === 'string' && args.assignee.trim() !== '' ? { assignee: args.assignee.trim() } : {}),
4140
4198
  created_by: cfg.machineName,
4141
4199
  // Only sent for broadcast; the relay ignores it otherwise, and the validation above already
4142
4200
  // refused the combination, so this cannot silently mean nothing.
@@ -4423,6 +4481,14 @@ export async function apply(ctx, config = {}) {
4423
4481
  const machines = Array.isArray(aggregate.detail?.machines)
4424
4482
  ? aggregate.detail.machines.map((machine) => {
4425
4483
  const envelope = machine?.envelope ?? machine?.result ?? null;
4484
+ // v1.2.6: the signature covers the command tuple; a reader is told which of the three states it is in.
4485
+ const relayVerdict = typeof machine?.signature_status === 'string' && machine.signature_status !== '' ? machine.signature_status : null;
4486
+ const signatureStatus = relayVerdict ?? verifyEnvelopeSignature({
4487
+ signature: envelope?.signature ?? null,
4488
+ task_id: envelope?.task_id ?? taskId,
4489
+ argv_hash: envelope?.command_hash,
4490
+ machine_id: envelope?.machine_id ?? machine?.machine_id,
4491
+ });
4426
4492
  return {
4427
4493
  machine_id: machine?.machine_id ?? null,
4428
4494
  machine_name: machine?.machine_name ?? null,
@@ -4440,6 +4506,7 @@ export async function apply(ctx, config = {}) {
4440
4506
  * v0.3.9 agent sends, so a missing value must not be dressed up as `'relay'`.
4441
4507
  */
4442
4508
  transport: envelope?.transport ?? machine?.transport ?? null,
4509
+ signature_status: signatureStatus,
4443
4510
  p2p: envelope?.p2p ?? machine?.p2p ?? null,
4444
4511
  reasons: machine?.reasons ?? [],
4445
4512
  envelope: compactEnvelope(envelope, COMPARABLE_FIELDS),
@@ -4447,19 +4514,39 @@ export async function apply(ctx, config = {}) {
4447
4514
  })
4448
4515
  : [];
4449
4516
 
4517
+ // v1.2.6: a signature state is never silently flattened into success.
4518
+ const signatureCounts = machines.reduce((counts, machine) => {
4519
+ const key = machine.signature_status ?? 'unsigned';
4520
+ counts[key] = (counts[key] ?? 0) + 1;
4521
+ return counts;
4522
+ }, {});
4523
+ const unsigned = signatureCounts.unsigned ?? 0;
4524
+ const badSignature = signatureCounts.bad_signature ?? 0;
4525
+
4450
4526
  // v1.2.4: a task whose machines produced blobs is only complete when EVERY map result names one and a reducer was
4451
4527
  // declared. The plan is built from the RAW envelopes: the comparable copy intentionally drops the fields this needs,
4452
4528
  // and a reduce over a partial fleet that reports itself complete is the failure this guards.
4453
4529
  const reducerMachineId = typeof args?.reducer_machine_id === 'string' && args.reducer_machine_id.trim() !== ''
4454
4530
  ? args.reducer_machine_id.trim()
4455
4531
  : null;
4456
- const rawMachines = Array.isArray(aggregate.detail?.machines) ? aggregate.detail.machines : [];
4457
- const mapResults = rawMachines.map((machine) => {
4532
+ // v1.2.7: the relay publishes machine facts directly on the aggregate; `detail.machines` is a test/report shape that the
4533
+ // real /v1/tasks response does not carry. Reading only the latter made the reduce plan silently unreachable in production.
4534
+ const rawMachines = Array.isArray(aggregate.detail?.machines)
4535
+ ? aggregate.detail.machines
4536
+ : (Array.isArray(aggregate.machines) ? aggregate.machines : []);
4537
+ // v1.2.7: only a machine that actually reported can claim a shard. An expired lease still carries an index, and counting
4538
+ // it as "produced" would make a partial run look complete -- the exact failure the expected-index check exists to prevent.
4539
+ const reportedMachines = rawMachines.filter((machine) => (machine?.envelope ?? machine?.result ?? null) !== null || typeof machine?.blob_sha256 === 'string' || typeof machine?.stdout_head === 'string' || Number.isFinite(machine?.stdout_bytes) || (typeof machine?.status === 'string' && machine.status !== '' && Number.isFinite(machine?.exit_code)));
4540
+ const mapResults = reportedMachines.map((machine) => {
4458
4541
  const envelope = machine?.envelope ?? machine?.result ?? null;
4459
- const blobSha = typeof envelope?.blob_sha256 === 'string' ? envelope.blob_sha256 : null;
4460
- const inline = typeof envelope?.stdout_head === 'string' && envelope.stdout_head !== '' ? envelope.stdout_head : null;
4542
+ const blobSha = typeof envelope?.blob_sha256 === 'string' ? envelope.blob_sha256
4543
+ : (typeof machine?.blob_sha256 === 'string' ? machine.blob_sha256 : null);
4544
+ const inline = typeof envelope?.stdout_head === 'string' && envelope.stdout_head !== '' ? envelope.stdout_head
4545
+ : (typeof machine?.stdout_head === 'string' && machine.stdout_head !== '' ? machine.stdout_head : null);
4461
4546
  return {
4462
4547
  machine_id: machine?.machine_id ?? null,
4548
+ // v1.2.7: the shard this result belongs to, so the reduce plan can tell "complete" from "the rest never reported".
4549
+ index: Number.isInteger(machine?.index) ? machine.index : (Number.isInteger(envelope?.index) ? envelope.index : null),
4463
4550
  mode: blobSha === null ? 'inline' : 'blob',
4464
4551
  ...(inline === null ? {} : { stdout: inline }),
4465
4552
  ...(blobSha === null ? {} : { blob_sha256: blobSha }),
@@ -4467,7 +4554,12 @@ export async function apply(ctx, config = {}) {
4467
4554
  });
4468
4555
  const anyBlob = mapResults.some((entry) => entry.mode === 'blob');
4469
4556
  const reduce = anyBlob && reducerMachineId !== null
4470
- ? buildReducePlan({ results: mapResults, reducerMachineId })
4557
+ ? buildReducePlan({
4558
+ results: mapResults,
4559
+ reducerMachineId,
4560
+ // v1.2.7: the task's own shard count is the completeness contract; absent it, the old "just what arrived" plan stands.
4561
+ expectedIndexTotal: Number.isInteger(body?.task?.index_total) ? body.task.index_total : null,
4562
+ })
4471
4563
  : null;
4472
4564
  const dataflowDegraded = reduce !== null && reduce.ok !== true;
4473
4565
 
@@ -4480,6 +4572,9 @@ export async function apply(ctx, config = {}) {
4480
4572
  state_known: aggregate.known,
4481
4573
  states: aggregate.states,
4482
4574
  machines,
4575
+ signatures: signatureCounts,
4576
+ ...(badSignature > 0 ? { signature_warning: 'bad_signature' } : {}),
4577
+ ...(badSignature === 0 && unsigned > 0 ? { signature_warning: 'unsigned' } : {}),
4483
4578
  ...(reduce === null ? {} : { reduce }),
4484
4579
  ...(dataflowDegraded ? { dataflow_degraded: true } : {}),
4485
4580
  counts: aggregate.detail?.counts ?? null,
@@ -6536,6 +6631,10 @@ export async function apply(ctx, config = {}) {
6536
6631
  type: 'string',
6537
6632
  description: 'For send/ask: replay protection. A retry with the same key returns the first message instead of posting the same sentence twice.',
6538
6633
  },
6634
+ reply_state: {
6635
+ type: 'string',
6636
+ description: 'For send/ask: answer the turn as "answered" or "refused". A refusal is a state the awaiter reads, not a wording it has to guess from.',
6637
+ },
6539
6638
  timeout_ms: { type: 'number', description: 'For await: how long to wait before reporting a timeout (default 60000, capped at 300000). A timeout is a value, not an error.' },
6540
6639
  },
6541
6640
  output: jsonOutput,
@@ -6545,10 +6644,16 @@ export async function apply(ctx, config = {}) {
6545
6644
  const action = typeof args?.action === 'string' ? args.action : 'list';
6546
6645
  const sessionId = typeof args?.session_id === 'string' ? args.session_id.trim() : '';
6547
6646
  if (sessionId === '') throw new W2MError('W2M_CHAT_SESSION', 'W2M_CHAT_SESSION: session_id is required', { hint: 'chat is scoped to a conversation' });
6647
+ // v1.2.7: expire stale turns on every entry, so `asks` reports the truth after downtime.
6648
+ askStore.expireDue();
6548
6649
  if (action === 'asks') {
6549
6650
  // The persisted questions for this conversation, in the states they are in. A read, so it needs no relay.
6550
6651
  return JSON.stringify({ ok: true, action, session_id: sessionId, ...askStore.list({ sessionId }) });
6551
6652
  }
6653
+ if (action === 'cursor') {
6654
+ const cursors = await readChatCursors(cfg.stateDir);
6655
+ return JSON.stringify({ ok: true, action, session_id: sessionId, cursor: Object.prototype.hasOwnProperty.call(cursors, sessionId) ? cursors[sessionId] : null, spool_count: (await readChatSpool(cfg.stateDir)).length });
6656
+ }
6552
6657
  const rabbitUrl = cfg.rabbitUrl ?? null;
6553
6658
  if (rabbitUrl === null) throw new W2MError('W2M_NO_RELAY', 'W2M_NO_RELAY: no relay is configured, so chat cannot be reached', { hint: 'set rabbitUrl in the profile patch' });
6554
6659
  // resolveToken, not readDevice: readDevice returns an empty identity whenever `stateDir` is
@@ -6557,6 +6662,22 @@ export async function apply(ctx, config = {}) {
6557
6662
  // one reported W2M_NO_TOKEN on a machine that was paired and answering every other call.
6558
6663
  const token = await resolveToken(cfg);
6559
6664
  if (token === null) throw new W2MError('W2M_NO_TOKEN', 'W2M_NO_TOKEN: this machine has no device token, so chat cannot be authenticated', { hint: 'pair this machine first (autoStartAgent or a pairing code), then retry' });
6665
+ const stateDir = typeof cfg.stateDir === 'string' ? cfg.stateDir : '';
6666
+ // v1.2.6 reliability: try to deliver what the last unreachable attempt had to leave behind, before doing anything else.
6667
+ let spool = await readChatSpool(stateDir);
6668
+ let spoolDelivered = 0;
6669
+ if (spool.length > 0) {
6670
+ const remaining = [];
6671
+ for (const entry of spool) {
6672
+ try {
6673
+ const replayed = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: entry.body });
6674
+ if (replayed?.ok === true) { spoolDelivered += 1; continue; }
6675
+ } catch { /* still unreachable: keep it for next time */ }
6676
+ remaining.push(entry);
6677
+ }
6678
+ spool = remaining;
6679
+ await writeChatSpool(stateDir, spool);
6680
+ }
6560
6681
  // Addressing + correlation travel only when the caller supplied them, so a plain broadcast stays byte-identical.
6561
6682
  const addressing = {};
6562
6683
  if (typeof args?.to === 'string' && args.to.trim() !== '') {
@@ -6570,64 +6691,180 @@ export async function apply(ctx, config = {}) {
6570
6691
  if (typeof args?.reply_to === 'string' && args.reply_to.trim() !== '') addressing.reply_to = args.reply_to.trim();
6571
6692
 
6572
6693
  if (action === 'await') {
6573
- // Waiting for an ANSWER is a state machine, and a timeout is a value the caller reads rather than an exception
6574
- // that loses the question. The question stays in the flow, so it can still be answered later.
6694
+ // v1.2.7: waiting is a persisted turn, not a poll loop.
6695
+ //
6696
+ // The question is written to the ask store when it is ASKED; await attaches to that record, so a restart in between
6697
+ // does not lose the question or turn a late answer into silence. The event stream is tried first (the reply arrives
6698
+ // when it is posted, not up to a poll interval later), and polling remains the fallback for a relay or proxy that
6699
+ // cannot stream -- an old relay must not break awaiting.
6575
6700
  const target = typeof args?.reply_to === 'string' ? args.reply_to.trim() : '';
6576
- if (target === '') throw new W2MError('W2M_CHAT_AWAIT', 'W2M_CHAT_AWAIT: reply_to is required for await', { hint: 'pass the message_id returned by ask' });
6701
+ if (target === '') throw new W2MError('W2M_CHAT_AWAIT', 'W2M_CHAT_AWAIT: reply_to is required for await', { hint: 'pass reply_to: <message_id of the question>' });
6577
6702
  const timeoutMs = Number.isFinite(args?.timeout_ms) ? Math.max(1000, Math.min(args.timeout_ms, 300000)) : 60000;
6578
6703
  const startedAt = Date.now();
6704
+ askStore.expireDue();
6705
+ let turn = askStore.list({ sessionId }).asks.find((ask) => (ask.state === 'open' || ask.state === 'timeout') && ask.message_id === target) ?? null;
6706
+ if (turn === null) {
6707
+ const opened = askStore.openAsk({ sessionId, messageId: target, timeoutMs });
6708
+ turn = opened.ok === true ? opened.ask : null;
6709
+ }
6710
+ const ownMachineId = cfg.machineId ?? null;
6711
+ const findReply = async () => {
6712
+ const listed = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query: { session_id: sessionId, limit: 200 } });
6713
+ const rows = Array.isArray(listed?.json?.messages) ? listed.json.messages : [];
6714
+ return rows.find((message) => message.reply_to === target && message.machine_id !== ownMachineId) ?? null;
6715
+ };
6716
+ const finish = (replyMessage, via) => {
6717
+ const verdict = replyMessage?.reply_state === 'refused' ? 'refused' : 'answered';
6718
+ if (turn !== null) askStore.settle(turn.ask_id, verdict, { messageId: replyMessage?.message_id ?? null });
6719
+ const recorded = turn === null ? null : (askStore.list({ sessionId }).asks.find((ask) => ask.ask_id === turn.ask_id) ?? null);
6720
+ return JSON.stringify({ ok: true, action, state: verdict, via, reply_to: target, reply: replyMessage, replies: replyMessage === null ? [] : [replyMessage], ask: recorded });
6721
+ };
6722
+ // 1) stream first -- and RE-subscribe while the budget remains: a dropped stream is not the end of the wait.
6723
+ let emptyStreams = 0;
6724
+ while (Date.now() - startedAt < timeoutMs) {
6725
+ const remaining = timeoutMs - (Date.now() - startedAt);
6726
+ const controller = new AbortController();
6727
+ const timer = setTimeout(() => controller.abort(), Math.min(remaining, 30000));
6728
+ let reachable = false;
6729
+ let gotEvent = false;
6730
+ try {
6731
+ const streamUrl = joinUrl(rabbitUrl, '/v1/fleet/messages/stream') + '?session_id=' + encodeURIComponent(sessionId);
6732
+ const response = await fetch(streamUrl, { headers: { authorization: 'Bearer ' + token }, signal: controller.signal });
6733
+ if (response.ok === true && response.body !== null) {
6734
+ reachable = true;
6735
+ const reader = response.body.getReader();
6736
+ const decoder = new TextDecoder();
6737
+ let buffer = '';
6738
+ for (;;) {
6739
+ const { value, done } = await reader.read();
6740
+ if (done === true) break;
6741
+ buffer += decoder.decode(value, { stream: true });
6742
+ const parts = buffer.split('\n\n');
6743
+ buffer = parts.pop() ?? '';
6744
+ for (const part of parts) {
6745
+ const line = part.split('\n').find((entry) => entry.startsWith('data: '));
6746
+ if (line === undefined) continue;
6747
+ gotEvent = true;
6748
+ const reply = await findReply();
6749
+ if (reply !== null) return finish(reply, 'stream');
6750
+ }
6751
+ }
6752
+ }
6753
+ } catch (error) {
6754
+ if (error?.name === 'AbortError' && Date.now() - startedAt >= timeoutMs) break;
6755
+ } finally { clearTimeout(timer); }
6756
+ if (reachable !== true) break; // a relay that will not stream at all: polling is the contract
6757
+ emptyStreams = gotEvent === true ? 0 : emptyStreams + 1;
6758
+ if (emptyStreams >= 2) break; // two streams that end immediately will not deliver this reply
6759
+ await new Promise((resolve) => { setTimeout(resolve, 100); });
6760
+ }
6761
+ // 2) polling fallback
6579
6762
  for (;;) {
6580
6763
  throwIfAborted(exec?.signal);
6581
- const reply = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query: { session_id: sessionId, reply_to: target, limit: '10' } });
6582
- const found = Array.isArray(reply?.json?.messages) ? reply.json.messages : [];
6583
- if (found.length > 0) {
6584
- for (const open of askStore.list({ sessionId }).asks) {
6585
- if (open.state === 'open' && open.message_id === target) askStore.settle(open.ask_id, 'answered', { messageId: found[0]?.message_id ?? null });
6586
- }
6587
- return JSON.stringify({ ok: true, action, state: 'answered', reply_to: target, replies: found });
6588
- }
6764
+ const reply = await findReply();
6765
+ if (reply !== null) return finish(reply, 'poll');
6589
6766
  if (Date.now() - startedAt >= timeoutMs) {
6590
- return JSON.stringify({ ok: true, action, state: 'timeout', reply_to: target, waited_ms: Date.now() - startedAt, hint: 'the question is still in the flow and can be answered later' });
6767
+ if (turn !== null) askStore.expire(turn.ask_id);
6768
+ const recorded = turn === null ? null : (askStore.list({ sessionId }).asks.find((ask) => ask.ask_id === turn.ask_id) ?? null);
6769
+ return JSON.stringify({ ok: true, action, state: 'timeout', reply_to: target, waited_ms: Date.now() - startedAt, ask: recorded, hint: 'the question stays in the flow and can still be answered later' });
6591
6770
  }
6592
- await new Promise((resolve) => { setTimeout(resolve, 1500); });
6771
+ await new Promise((resolve) => { setTimeout(resolve, 1000); });
6593
6772
  }
6594
6773
  }
6595
6774
  if (action === 'send' || action === 'ask') {
6596
6775
  const mentions = Array.isArray(args?.mentions) ? args.mentions.filter((id) => typeof id === 'string' && id !== '') : [];
6597
- const posted = await request({
6598
- rabbitUrl,
6599
- token,
6600
- signal: exec?.signal,
6601
- method: 'POST',
6602
- pathname: '/v1/fleet/messages',
6603
- body: {
6604
- session_id: sessionId,
6605
- text: typeof args?.text === 'string' ? args.text : '',
6606
- ...addressing,
6607
- // v1.2.5: a mention is a notification hint; the idempotency key lets a retry after a lost response replay the
6608
- // first message instead of posting the same sentence twice.
6609
- ...(mentions.length === 0 ? {} : { mentions }),
6610
- ...(typeof args?.idempotency_key === 'string' && args.idempotency_key.trim() !== '' ? { idempotency_key: args.idempotency_key.trim() } : {}),
6611
- },
6612
- });
6776
+ /**
6777
+ * v1.2.6: the chat is end-to-end encrypted when a fleet key is available (configured, or derived from the account join).
6778
+ * Without one the tool keeps sending plaintext rather than refusing: a fleet that has not joined yet is a normal state,
6779
+ * and a silent refusal would read as a broken chat.
6780
+ */
6781
+ const chatKey = deriveChatKey(cfg.fleetKey ?? resolvedFleetKey.value ?? null);
6782
+ const plainText = typeof args?.text === 'string' ? args.text : '';
6783
+ const sealed = chatKey === null ? null : encryptChatText(chatKey, plainText);
6784
+ /**
6785
+ * v1.2.6 reliability: an unreachable Rabbit does not lose the message. The exact body that could not be sent is
6786
+ * spooled to disk and replayed with the SAME idempotency key next time, so a message the relay actually received
6787
+ * before the connection died is not posted twice.
6788
+ */
6789
+ const messageBody = {
6790
+ session_id: sessionId,
6791
+ text: sealed !== null && sealed.ok === true ? sealed.text : plainText,
6792
+ ...(sealed !== null && sealed.ok === true ? { enc: sealed.enc } : {}),
6793
+ ...addressing,
6794
+ ...(mentions.length === 0 ? {} : { mentions }),
6795
+ ...(typeof args?.idempotency_key === 'string' && args.idempotency_key.trim() !== '' ? { idempotency_key: args.idempotency_key.trim() } : { idempotency_key: 'spool-' + Date.now() + '-' + Math.random().toString(36).slice(2) }),
6796
+ ...(typeof args?.reply_state === 'string' && (args.reply_state === 'answered' || args.reply_state === 'refused') ? { reply_state: args.reply_state } : {}),
6797
+ };
6798
+ let posted = null;
6799
+ try {
6800
+ posted = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: messageBody });
6801
+ } catch (error) {
6802
+ if (error?.code === 'W2M_UNREACHABLE') {
6803
+ spool.push({ body: messageBody, created_at: new Date().toISOString() });
6804
+ await writeChatSpool(stateDir, spool);
6805
+ return JSON.stringify({ ok: true, action, spooled: true, spool_count: spool.length, encrypted: sealed !== null && sealed.ok === true });
6806
+ }
6807
+ throw error;
6808
+ }
6613
6809
  if (posted?.ok !== true) {
6614
6810
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'W2M_CHAT_SEND';
6615
6811
  throw new W2MError('W2M_CHAT_SEND', code + ': the message was refused', { reason_code: code, hint: 'quota, size and non-text each need a different answer' });
6616
6812
  }
6617
- return JSON.stringify({ ok: true, action, message: posted.json?.message ?? null, message_id: posted.json?.message?.message_id ?? null, ...(posted.json?.deduped === true ? { deduped: true } : {}) });
6813
+ let ask = null;
6814
+ if (action === 'ask' && posted.json?.message?.message_id !== undefined) {
6815
+ // v1.2.7: the question becomes a turn on disk the moment it is asked, so a later await (in this process or after a
6816
+ // restart) settles the same record instead of opening a second one.
6817
+ const opened = askStore.openAsk({
6818
+ sessionId,
6819
+ messageId: posted.json.message.message_id,
6820
+ threadId: typeof args?.thread_id === 'string' ? args.thread_id : null,
6821
+ to: typeof args?.to === 'string' ? args.to : null,
6822
+ timeoutMs: Number.isFinite(args?.timeout_ms) ? args.timeout_ms : 60000,
6823
+ });
6824
+ if (opened.ok === true) ask = opened.ask;
6825
+ }
6826
+ return JSON.stringify({
6827
+ ok: true, action, message: posted.json?.message ?? null, message_id: posted.json?.message?.message_id ?? null,
6828
+ encrypted: sealed !== null && sealed.ok === true,
6829
+ ...(ask === null ? {} : { ask }),
6830
+ ...(posted.json?.deduped === true ? { deduped: true } : {}),
6831
+ });
6618
6832
  }
6833
+ const query = { session_id: sessionId };
6834
+ // v1.2.6 reliability: continue where the last page ended, unless the caller asked for an explicit start.
6835
+ const cursors = await readChatCursors(stateDir);
6836
+ const storedCursor = Object.prototype.hasOwnProperty.call(cursors, sessionId) ? cursors[sessionId] : null;
6837
+ if (!Number.isFinite(args?.since_ms) && storedCursor !== null && Number.isFinite(storedCursor.at_ms)) query.since_ms = String(storedCursor.at_ms);
6619
6838
  if (typeof args?.thread_id === 'string' && args.thread_id.trim() !== '') query.thread_id = args.thread_id.trim();
6620
6839
  if (typeof args?.to === 'string' && args.to.trim() !== '') query.to = args.to.trim();
6621
- const query = { session_id: sessionId };
6622
6840
  if (Number.isFinite(args?.since_ms)) query.since_ms = String(args.since_ms);
6623
6841
  if (Number.isInteger(args?.limit)) query.limit = String(args.limit);
6624
6842
  const listed = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query });
6625
6843
  if (listed?.ok !== true) throw new W2MError('W2M_CHAT_LIST', 'W2M_CHAT_LIST: the relay refused the read', { hint: 'check the relay and this machine token' });
6626
- const messages = listed.json?.messages ?? [];
6844
+ const rawMessages = listed.json?.messages ?? [];
6845
+ /**
6846
+ * v1.2.6: a reader decrypts what its key covers and marks the rest. An undecryptable message keeps its ciphertext and its
6847
+ * reason instead of failing the page -- the relay is allowed to hold a message this machine cannot open.
6848
+ */
6849
+ const listKey = deriveChatKey(cfg.fleetKey ?? resolvedFleetKey.value ?? null);
6850
+ const messages = rawMessages.map((message) => {
6851
+ if (!isEncrypted(message)) return message;
6852
+ const opened = decryptChatMessage(listKey, message);
6853
+ if (opened.ok === true) return { ...message, text: opened.text, decrypted: true };
6854
+ return { ...message, text: '', decrypted: false, encrypted: true, decrypt_error: opened.code };
6855
+ });
6856
+ const encryptedCount = messages.filter((message) => message.encrypted === true || message.decrypted === true).length;
6857
+ // The cursor advances only over what was actually read, so a page that failed mid-way does not skip messages.
6858
+ const newestAt = messages.reduce((max, message) => (Number.isFinite(message?.at_ms) && message.at_ms > max ? message.at_ms : max), 0);
6859
+ let cursor = storedCursor;
6860
+ if (newestAt > 0 && (storedCursor === null || newestAt > storedCursor.at_ms)) {
6861
+ cursor = { at_ms: newestAt, message_id: messages.filter((message) => message.at_ms === newestAt)[0]?.message_id ?? null };
6862
+ await writeChatCursors(stateDir, { ...cursors, [sessionId]: cursor });
6863
+ }
6627
6864
  // v1.2.4: the page is annotated with turns derived from the relay's own numbering, so a reader does not have to order
6628
6865
  // replies by arrival time.
6629
6866
  const turns = deriveTurns(messages);
6630
- return JSON.stringify({ ok: true, action, session_id: sessionId, messages, count: messages.length, turns: turns.threads, inferred_turns: turns.inferred_turns, count_was: listed.json?.count ?? 0, usage: listed.json?.usage ?? null });
6867
+ return JSON.stringify({ ok: true, action, session_id: sessionId, messages, count: messages.length, encrypted_count: encryptedCount, cursor, spooled: spoolDelivered, turns: turns.threads ?? [], inferred_turns: turns.inferred_turns ?? [] });
6631
6868
  },
6632
6869
  }));
6633
6870
 
@@ -6941,6 +7178,11 @@ export async function apply(ctx, config = {}) {
6941
7178
  mode: row?.mode ?? null,
6942
7179
  status: row?.status ?? null,
6943
7180
  cancelled: row?.cancelled === true,
7181
+ // v1.2.7: graph facts travel with each row so the board and the scheduler cannot disagree.
7182
+ depends_on: Array.isArray(row?.depends_on) ? row.depends_on : [],
7183
+ assignee: row?.assignee ?? null,
7184
+ waiting_on: Array.isArray(row?.waiting_on) ? row.waiting_on : [],
7185
+ lease_states: row?.lease_states ?? {},
6944
7186
  machines: Array.isArray(row?.leases)
6945
7187
  ? row.leases.map((lease) => lease?.machine_id ?? null).filter((id) => typeof id === "string")
6946
7188
  : [],
@@ -7182,6 +7424,8 @@ async function startLocalStack(ctx, cfg) {
7182
7424
  caps,
7183
7425
  platform,
7184
7426
  allowedCommands: cfg.allowedCommands,
7427
+ // v1.2.6: on by default; a profile may turn it off for debugging, and the envelope then says `unredacted: true`.
7428
+ redactOutput: cfg.redactOutput !== false,
7185
7429
  });
7186
7430
  /**
7187
7431
  * v0.8.9: derive the fleet key, RETRYING until the account actually answers.
@@ -105,6 +105,20 @@ export function createFleetMessages(options = {}) {
105
105
  if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
106
106
  // Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
107
107
  // exchange inside one thread. Plain fields, validated rather than trusted.
108
+ /**
109
+ * v1.2.6 end-to-end encryption: the relay carries the ciphertext and its metadata, and never a key. It validates the SHAPE
110
+ * because a malformed envelope would be stored and later fail on every reader; it cannot check the content, and that is the
111
+ * point -- which also means it cannot search messages or know their real length.
112
+ */
113
+ const rawEnc = input?.enc;
114
+ let enc = null;
115
+ if (rawEnc !== undefined && rawEnc !== null) {
116
+ const ivOk = typeof rawEnc.iv === 'string' && rawEnc.iv.length > 0 && rawEnc.iv.length <= 64;
117
+ const tagOk = typeof rawEnc.tag === 'string' && rawEnc.tag.length > 0 && rawEnc.tag.length <= 64;
118
+ if (rawEnc.v !== 1 || rawEnc.alg !== 'aes-256-gcm' || !ivOk || !tagOk) return { ok: false, code: 'BAD_ENCRYPTION' };
119
+ enc = { v: 1, alg: 'aes-256-gcm', iv: rawEnc.iv, tag: rawEnc.tag };
120
+ }
121
+
108
122
  // v1.2.5 idempotency: replay the first message instead of storing the text twice.
109
123
  const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
110
124
  ? input.idempotency_key.trim().slice(0, 64)
@@ -129,6 +143,11 @@ export function createFleetMessages(options = {}) {
129
143
  mentions = [...new Set(rawMentions)];
130
144
  }
131
145
 
146
+ // v1.2.7: a reply can be an ANSWER or a REFUSAL, and the difference is a state, not a wording. Validated here so the
147
+ // turn state machine reads the sender's own word instead of guessing from text.
148
+ const replyState = input?.reply_state === undefined || input?.reply_state === null ? null : input.reply_state;
149
+ if (replyState !== null && replyState !== 'answered' && replyState !== 'refused') return { ok: false, code: 'BAD_REPLY_STATE' };
150
+
132
151
  const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
133
152
  ? input.thread_id.trim().slice(0, 64)
134
153
  : null;
@@ -161,7 +180,11 @@ export function createFleetMessages(options = {}) {
161
180
  text,
162
181
  // Omitted when empty so a message without mentions keeps the pre-1.2.5 shape.
163
182
  ...(mentions.length === 0 ? {} : { mentions }),
183
+ // v1.2.6: ciphertext metadata travels beside the text so an old client can read it as opaque text and a new one can name
184
+ // the cipher it does not have a key for.
185
+ ...(enc === null ? {} : { enc }),
164
186
  ...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
187
+ ...(replyState === null ? {} : { reply_state: replyState }),
165
188
  at_ms: now(),
166
189
  bytes,
167
190
  };
@@ -148,6 +148,8 @@ export function aggregate(task, resultRecords = [], opts = {}) {
148
148
  observing: lease.state === 'observing',
149
149
  result: rec,
150
150
  envelope: rec?.envelope ?? null,
151
+ // v1.2.6: the relay's own signature verdict (bound to the key registered at pairing), not a client claim.
152
+ signature_status: rec?.validation?.signature_status ?? null,
151
153
  outcome: null,
152
154
  reasons: [],
153
155
  });
@@ -166,6 +168,7 @@ export function aggregate(task, resultRecords = [], opts = {}) {
166
168
  gate_refused: false,
167
169
  result: rec,
168
170
  envelope: rec.envelope,
171
+ signature_status: rec?.validation?.signature_status ?? null,
169
172
  outcome: null,
170
173
  reasons: [],
171
174
  });
@@ -487,6 +490,19 @@ export function aggregate(task, resultRecords = [], opts = {}) {
487
490
  transport: m.envelope?.transport ?? null,
488
491
  p2p: m.envelope?.p2p ?? null,
489
492
  reasons: m.reasons,
493
+ // v1.2.6: the relay's verdict, bound to the key registered at pairing.
494
+ signature_status: m.signature_status ?? null,
495
+ /**
496
+ * v1.2.7: the dataflow facts a reader needs to build a reduce plan.
497
+ *
498
+ * The aggregate used to publish only a summary per machine, so a client could not see that a result was a blob -- the
499
+ * reduce plan existed in tests and read raw envelopes that the production response never carried. These four fields are
500
+ * that omission, closed: the blob hash, the file/blob mode, the inline head, and the byte count.
501
+ */
502
+ blob_sha256: m.envelope?.blob_sha256 ?? null,
503
+ output_mode: m.envelope?.output_mode ?? null,
504
+ stdout_head: m.envelope?.stdout_head ?? null,
505
+ stdout_bytes: m.envelope?.stdout_bytes ?? null,
490
506
  })),
491
507
  steps,
492
508
  differences: step3.differences ?? [],
@@ -22,7 +22,16 @@ import {
22
22
  RabbitState,
23
23
  rfc3339,
24
24
  } from './state.mjs';
25
+
26
+ // v1.2.7: the content-addressed blob channel carries ARTIFACTS, so its frame limit cannot be the JSON frame limit.
27
+ //
28
+ // MAX_FRAME_BYTES (64 KiB) is exactly the inline threshold: any blob is larger than an inline result by construction, so with
29
+ // one shared limit the blob channel could never accept a single byte -- a pipeline blob upload from a real relay failed with
30
+ // FRAME_TOO_LARGE while the fake relay in the tests accepted it. The dataflow hard cap stays the agent-side bound; this is the
31
+ // transport bound, and it is still bounded.
32
+ export const BLOB_FRAME_BYTES = 1024 * 1024; // 1 MiB per blob frame
25
33
  import { createFleetMessages } from './fleet-messages.mjs';
34
+ import { can as mayAct } from '../util/policy-matrix.mjs';
26
35
  import { createFleetMemory } from './fleet-memory.mjs';
27
36
  import { createJoinChallenges } from './join-challenge.mjs';
28
37
  import { createAdmission } from './admission.mjs';
@@ -686,7 +695,9 @@ export class RelayServer {
686
695
 
687
696
  // v0.3.0 §9: read the body ONCE, before routing. An HMAC covers the exact
688
697
  // received bytes, so no route may consume the stream first and re-serialise.
689
- const rawBody = methodHasBody(method) ? await readRawBody(req, this.frameLimitBytes) : null;
698
+ /** v1.2.7: only the blob channel gets the larger frame cap; every JSON route keeps the 64 KiB frame limit. */
699
+ const blobFrameLimit = method === 'PUT' && path.startsWith('/v1/fleet/blobs/') ? BLOB_FRAME_BYTES : this.frameLimitBytes;
700
+ const rawBody = methodHasBody(method) ? await readRawBody(req, blobFrameLimit) : null;
690
701
  const takeJson = () => parseJsonBody(rawBody);
691
702
 
692
703
  /* ---- public endpoints ---- */
@@ -1114,6 +1125,25 @@ export class RelayServer {
1114
1125
  this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
1115
1126
 
1116
1127
  if (method === 'POST' && path === '/v1/fleet/messages') {
1128
+ /**
1129
+ * v1.2.6: posting to the group is a dispatch-class action. An observer may read the flow and may not speak into it, and
1130
+ * the refusal names the action and both roles so it is actionable.
1131
+ */
1132
+ const permitted = mayAct({ role: this.state.roleOf(device.machine_id), action: 'dispatch' });
1133
+ if (permitted.ok !== true) {
1134
+ return sendJson(res, 403, {
1135
+ protocol_version: PROTOCOL_VERSION,
1136
+ ok: false,
1137
+ error: { code: permitted.code, message: 'role may not post to the fleet group' },
1138
+ detail: {
1139
+ action: permitted.action,
1140
+ required_level: permitted.required_level,
1141
+ current_level: permitted.current_level,
1142
+ required_role: permitted.required_role,
1143
+ current_role: permitted.current_role,
1144
+ },
1145
+ });
1146
+ }
1117
1147
  const body = takeJson();
1118
1148
  const posted = this.fleetMessages.post({
1119
1149
  session_id: body.session_id,
@@ -1124,6 +1154,8 @@ export class RelayServer {
1124
1154
  reply_to: body.reply_to,
1125
1155
  text: body.text,
1126
1156
  mentions: body.mentions,
1157
+ enc: body.enc,
1158
+ reply_state: body.reply_state,
1127
1159
  idempotency_key: body.idempotency_key,
1128
1160
  });
1129
1161
  if (posted.ok !== true) {