@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +121 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +8 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +4436 -4360
- package/src/agent/crypto.mjs +31 -0
- package/src/plugin/task-graph.mjs +153 -147
- package/src/plugin/tools.mjs +284 -40
- package/src/relay/fleet-messages.mjs +23 -0
- package/src/relay/report.mjs +16 -0
- package/src/relay/server.mjs +33 -1
- package/src/relay/state.mjs +157 -2
- package/src/util/chat-crypto.mjs +98 -0
- package/src/util/dataflow.mjs +24 -1
- package/src/util/envelope-signature.mjs +69 -0
- package/src/util/policy-matrix.mjs +65 -0
- package/src/util/redact.mjs +70 -0
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
4457
|
-
|
|
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
|
|
4460
|
-
|
|
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({
|
|
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
|
-
//
|
|
6574
|
-
//
|
|
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
|
|
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
|
|
6582
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
};
|
package/src/relay/report.mjs
CHANGED
|
@@ -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 ?? [],
|
package/src/relay/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|