@twinsearth/w2m-dsh-plugin 0.5.3 → 0.5.5
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 +90 -0
- package/cordis.patch.yml +27 -4
- package/lib/client.js +515 -0
- package/lib/index.js +16 -11
- package/lib/tools.js +9 -3
- package/package.json +7 -3
- package/src/plugin/fleet-target.mjs +667 -0
- package/src/plugin/tools.mjs +152 -26
package/src/plugin/tools.mjs
CHANGED
|
@@ -85,6 +85,18 @@ import { parseServer } from '../agent/stun.mjs';
|
|
|
85
85
|
// the dedupe key exists to prevent.
|
|
86
86
|
import { SHELL_ID_DIRECT, computeCommandHash, computeDedupeKey } from '../relay/state.mjs';
|
|
87
87
|
import { createAutoUpdater, findProfileDir, resolveCurrentVersion } from './auto-update.mjs';
|
|
88
|
+
// v0.6.1: the session's chosen target machine, and the two browser routes the composer control
|
|
89
|
+
// reads it through. Imported rather than inlined because the control and `w2m_run` must agree on
|
|
90
|
+
// what "selected" means, and a second copy of that rule is a second chance to disagree.
|
|
91
|
+
import {
|
|
92
|
+
createLocalIdentity,
|
|
93
|
+
defaultSelectionStore,
|
|
94
|
+
dispatchSelectionFields,
|
|
95
|
+
readFleet,
|
|
96
|
+
registerTargetRoutes,
|
|
97
|
+
sessionKeyFor,
|
|
98
|
+
TARGET_ROUTE_PATH,
|
|
99
|
+
} from './fleet-target.mjs';
|
|
88
100
|
import { loadSharedConfig, describeSharedConfig } from './shared-config.mjs';
|
|
89
101
|
import { DEFAULT_DAILY_TIMES, DEFAULT_TIME_ZONE } from './schedule.mjs';
|
|
90
102
|
|
|
@@ -119,7 +131,7 @@ const MAX_STAGES = 16;
|
|
|
119
131
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
120
132
|
* overrides it at runtime for tests and packagers.
|
|
121
133
|
*/
|
|
122
|
-
const PLUGIN_VERSION = '0.5.
|
|
134
|
+
const PLUGIN_VERSION = '0.5.5';
|
|
123
135
|
|
|
124
136
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
125
137
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -2845,6 +2857,35 @@ export async function apply(ctx, config = {}) {
|
|
|
2845
2857
|
// still has to dispatch, and `w2m_status` is where the failure is reported.
|
|
2846
2858
|
const p2p = createP2PRuntime(ctx, cfg, config.p2pBlock);
|
|
2847
2859
|
|
|
2860
|
+
/**
|
|
2861
|
+
* This machine's fleet identity, read once per successful resolution.
|
|
2862
|
+
*
|
|
2863
|
+
* The memoization rule lives in {@link createLocalIdentity} so the route half and the tool half
|
|
2864
|
+
* cannot disagree about when a missing identity becomes a stale one.
|
|
2865
|
+
*/
|
|
2866
|
+
const localIdentity = createLocalIdentity(async () => {
|
|
2867
|
+
const device = await readDevice({ stateDir: cfg.stateDir });
|
|
2868
|
+
return { machine_id: device.machine_id, machine_name: device.machine_name, machineName: cfg.machineName };
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2871
|
+
// v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
|
|
2872
|
+
// host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence —
|
|
2873
|
+
// which is the honest place for it, since a host that has no Connection at all is simply not a web
|
|
2874
|
+
// composition and does not need a warning on every headless run.
|
|
2875
|
+
//
|
|
2876
|
+
// Nothing here reads the service: `registerTargetRoutes` declares the optional dependency with
|
|
2877
|
+
// `ctx.inject` and attaches from that callback (the comment there records why a plain
|
|
2878
|
+
// `ctx.connection` read fails the whole entry, and why the read cannot be synchronous anyway). The
|
|
2879
|
+
// one mismatch worth a log line — a Connection that carries no fetch registry — is logged where it
|
|
2880
|
+
// is observed, inside that callback, so this call returns a status object that stays live.
|
|
2881
|
+
const targetRoutes = registerTargetRoutes(ctx, {
|
|
2882
|
+
cfg: () => cfg,
|
|
2883
|
+
store: defaultSelectionStore,
|
|
2884
|
+
localIdentity,
|
|
2885
|
+
request,
|
|
2886
|
+
resolveToken,
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2848
2889
|
/** Text output: rendered as JSON, because every one of these tools answers with a structure. */
|
|
2849
2890
|
const jsonOutput = {
|
|
2850
2891
|
schema: { type: 'string' },
|
|
@@ -2872,48 +2913,61 @@ export async function apply(ctx, config = {}) {
|
|
|
2872
2913
|
throwIfAborted(exec?.signal);
|
|
2873
2914
|
const base = requireRabbit(cfg);
|
|
2874
2915
|
const token = await resolveToken(cfg);
|
|
2875
|
-
|
|
2916
|
+
|
|
2917
|
+
// The read itself, the liveness rule, and "this machine has no identity yet" all come from
|
|
2918
|
+
// the same helper the composer control uses, so the list a model reads and the list an
|
|
2919
|
+
// operator clicks can never disagree about who is online.
|
|
2920
|
+
const identity = await localIdentity();
|
|
2921
|
+
const fleet = await readFleet({
|
|
2876
2922
|
rabbitUrl: base,
|
|
2877
2923
|
token,
|
|
2924
|
+
localMachineId: identity.machine_id,
|
|
2878
2925
|
signal: exec?.signal,
|
|
2879
|
-
|
|
2926
|
+
request,
|
|
2880
2927
|
});
|
|
2881
|
-
if (!
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2928
|
+
if (!fleet.ok) {
|
|
2929
|
+
// The typed error is rebuilt here, from the helper's report, so the code, message and hint a
|
|
2930
|
+
// caller sees are the same three it saw before this read moved into `fleet-target.mjs`: the
|
|
2931
|
+
// remediation travels in `hint`, never in the message.
|
|
2932
|
+
const unauthorized = fleet.status === 401;
|
|
2933
|
+
throw new W2MError(
|
|
2934
|
+
unauthorized ? 'W2M_RABBIT_REFUSED' : 'W2M_UNREACHABLE',
|
|
2935
|
+
fleet.status === 0
|
|
2936
|
+
? `${fleet.error}`
|
|
2937
|
+
: `${fleet.error}; ${unauthorized ? 'this machine is not paired, or its device_token is stale - pair the agent again' : 'check the Rabbit log for the reason it gave'}`,
|
|
2938
|
+
{
|
|
2939
|
+
hint: fleet.hint ?? (unauthorized
|
|
2940
|
+
? 'this machine is not paired, or its device_token is stale; pair the agent again'
|
|
2941
|
+
: 'check the Rabbit log for the reason it gave'),
|
|
2942
|
+
},
|
|
2943
|
+
);
|
|
2887
2944
|
}
|
|
2888
2945
|
|
|
2889
2946
|
// `GET /v1/devices` answers `{protocol_version, rabbit_time, devices:[...]}`. Each entry is a
|
|
2890
2947
|
// device recor - the Rabbit's own lease bookkeeping is on the task, not her - so a device
|
|
2891
|
-
// is described by identity and capabilities, and
|
|
2892
|
-
//
|
|
2893
|
-
const machines = Array.isArray(result.json?.devices)
|
|
2894
|
-
? result.json.devices
|
|
2895
|
-
: (Array.isArray(result.json?.machines) ? result.json.machines : []);
|
|
2948
|
+
// is described by identity and capabilities, and the relay's own `state` is the honest
|
|
2949
|
+
// default for a device it still lists.
|
|
2896
2950
|
const includeStale = args?.include_stale === true;
|
|
2897
|
-
const listed =
|
|
2951
|
+
const listed = fleet.records.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
|
|
2898
2952
|
|
|
2899
2953
|
return JSON.stringify(
|
|
2900
2954
|
{
|
|
2901
2955
|
ok: true,
|
|
2902
2956
|
protocol_version: PROTOCOL_VERSION,
|
|
2903
|
-
rabbit_time:
|
|
2957
|
+
rabbit_time: fleet.rabbit_time,
|
|
2904
2958
|
count: listed.length,
|
|
2905
2959
|
devices: listed.map((machine) => ({
|
|
2906
2960
|
machine_id: machine?.machine_id ?? null,
|
|
2907
2961
|
name: machine?.machine_name ?? machine?.name ?? null,
|
|
2908
|
-
os: machine?.platform?.os ?? null,
|
|
2909
|
-
os_version: machine?.platform?.os_version ?? null,
|
|
2910
|
-
arch: machine?.platform?.arch ?? null,
|
|
2911
|
-
shell: machine?.platform?.shell ?? null,
|
|
2962
|
+
os: machine?.platform?.os ?? machine?.os ?? null,
|
|
2963
|
+
os_version: machine?.platform?.os_version ?? machine?.os_version ?? null,
|
|
2964
|
+
arch: machine?.platform?.arch ?? machine?.arch ?? null,
|
|
2965
|
+
shell: machine?.platform?.shell ?? machine?.shell ?? null,
|
|
2912
2966
|
caps: machine?.caps ?? null,
|
|
2913
2967
|
last_seen: machine?.last_seen ?? machine?.last_seen_at ?? null,
|
|
2914
2968
|
state: machine?.state ?? 'online',
|
|
2915
2969
|
})),
|
|
2916
|
-
excluded_stale: includeStale ? 0 :
|
|
2970
|
+
excluded_stale: includeStale ? 0 : fleet.records.length - listed.length,
|
|
2917
2971
|
note:
|
|
2918
2972
|
'a listed device has paired and is being tracked; it can still refuse the next command ' +
|
|
2919
2973
|
'at the capability gate, which w2m_wait reports per machine as `refused`',
|
|
@@ -2937,7 +2991,10 @@ export async function apply(ctx, config = {}) {
|
|
|
2937
2991
|
'Anchors (base_commit, base_tree) are computed here from this machine\'s working tree so ' +
|
|
2938
2992
|
'the peers can be checked against them. Under the default p2pMode the offer is also pushed ' +
|
|
2939
2993
|
'over the direct path, best effort: `direct_pushes` names every attempt, and the relay\'s ' +
|
|
2940
|
-
'own offer remains the delivery when a push fails.'
|
|
2994
|
+
'own offer remains the delivery when a push fails. ' +
|
|
2995
|
+
'A machine selected in the composer for this session narrows the dispatch to that machine; ' +
|
|
2996
|
+
'an explicit `executor_machine_id` always wins over it, and the answer\'s `selection` says ' +
|
|
2997
|
+
'which of the two routed the task.',
|
|
2941
2998
|
parameters: {
|
|
2942
2999
|
command_argv: {
|
|
2943
3000
|
type: 'array',
|
|
@@ -3160,6 +3217,34 @@ export async function apply(ctx, config = {}) {
|
|
|
3160
3217
|
);
|
|
3161
3218
|
}
|
|
3162
3219
|
|
|
3220
|
+
// -------------------------------------------------------------------------------------------
|
|
3221
|
+
// v0.6.1: the session's chosen target, when the caller named no machine of its own.
|
|
3222
|
+
// -------------------------------------------------------------------------------------------
|
|
3223
|
+
//
|
|
3224
|
+
// The composer control writes a selection into this process, keyed by session; here is where
|
|
3225
|
+
// it becomes real. Read before the payload literal so the routing facts and the answer agree.
|
|
3226
|
+
//
|
|
3227
|
+
// The precedence rule is the whole contract, and it is one line: **the caller's own fields
|
|
3228
|
+
// always win**. `executor_machine_id` is checked below, and `target_machines` is never a tool
|
|
3229
|
+
// argument — so a caller that named a machine (which, for this tool, means
|
|
3230
|
+
// `executor_machine_id` under broadcast) is untouched, and a caller that named none inherits
|
|
3231
|
+
// the selection. Nothing already published changes meaning: the selection only fills fields
|
|
3232
|
+
// that were absent, and the relay has accepted both of them since v0.3.3/v0.4.1.
|
|
3233
|
+
const sessionKey = sessionKeyFor(exec);
|
|
3234
|
+
const selection = defaultSelectionStore.get(sessionKey);
|
|
3235
|
+
const routedBy = dispatchSelectionFields(selection, mode);
|
|
3236
|
+
// broadcast reads the executor field; everything else reads `target_machines`. The relay
|
|
3237
|
+
// ignores the field a mode does not read, and the two are never both set by a selection.
|
|
3238
|
+
const selectionFields = mode === 'broadcast' && executorMachineId !== null
|
|
3239
|
+
? { fields: {}, applied_as: null }
|
|
3240
|
+
: routedBy.fields;
|
|
3241
|
+
// Whether the selection actually routed THIS task. Reported in the answer, because it is the
|
|
3242
|
+
// one thing an operator cannot recover afterwards: the payload is not in the transcript, and
|
|
3243
|
+
// the same command run twice can legitimately take two different routes.
|
|
3244
|
+
const selectionAppliedAs = mode === 'broadcast' && executorMachineId !== null
|
|
3245
|
+
? null
|
|
3246
|
+
: routedBy.applied_as;
|
|
3247
|
+
|
|
3163
3248
|
// Plugin-side allow-list check: only when one is configured. See checkAllowedCommand.
|
|
3164
3249
|
//
|
|
3165
3250
|
// **Every** stage is checked, not just the first. Checking only `command_argv` would let a
|
|
@@ -3256,6 +3341,10 @@ export async function apply(ctx, config = {}) {
|
|
|
3256
3341
|
// Only sent for broadcast; the relay ignores it otherwise, and the validation above already
|
|
3257
3342
|
// refused the combination, so this cannot silently mean nothing.
|
|
3258
3343
|
...(executorMachineId !== null ? { executor_machine_id: executorMachineId } : {}),
|
|
3344
|
+
// v0.6.1: the elected machine, when the caller did not name one and a mode that reads it is
|
|
3345
|
+
// in play. Spread after the caller's own field above so the caller always wins even if a
|
|
3346
|
+
// future edit makes both possible.
|
|
3347
|
+
...selectionFields,
|
|
3259
3348
|
// Only for pipeline. The relay ignores `stages` under the other modes and the validation
|
|
3260
3349
|
// above refuses the combination, so this can never silently mean nothing.
|
|
3261
3350
|
...(mode === 'pipeline' && stagesPayload !== null ? { stages: stagesPayload } : {}),
|
|
@@ -3279,15 +3368,22 @@ export async function apply(ctx, config = {}) {
|
|
|
3279
3368
|
// OPERATOR_REQUIRED gets its own guidance: it is the one refusal whose fix is a client-side
|
|
3280
3369
|
// setting rather than something on the relay.
|
|
3281
3370
|
const operatorRequired = result.error?.code === 'OPERATOR_REQUIRED' || result.status === 401;
|
|
3371
|
+
// v0.6.1: when a task was elected to one machine, NO_ONLINE_DEVICE is the *machine* being
|
|
3372
|
+
// unreachable, not the fleet being empty -- and the two have different fixes. Saying so is
|
|
3373
|
+
// the difference between "start an agent" and "the machine you picked went away".
|
|
3374
|
+
const selectedName = selection?.machine_name ?? selection?.machine_id ?? null;
|
|
3375
|
+
const selectedMissing = result.error?.code === 'NO_ONLINE_DEVICE' && selectionAppliedAs !== null;
|
|
3282
3376
|
throw new W2MError(
|
|
3283
3377
|
'W2M_RABBIT_REFUSED',
|
|
3284
|
-
`W2M_RABBIT_REFUSED: the Rabbit refused POST /v1/task with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${operatorRequired ? 'this relay requires the operator token to dispatch tasks and the one sent was not accepted - set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup; a device token cannot dispatch tasks' : result.error?.code === 'NO_ONLINE_DEVICE' ? 'no machine is currently streaming - start the agent on each machine, then w2m_devices shows who is online' : 'check the Rabbit log for the reason it gave'}`,
|
|
3378
|
+
`W2M_RABBIT_REFUSED: the Rabbit refused POST /v1/task with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${operatorRequired ? 'this relay requires the operator token to dispatch tasks and the one sent was not accepted - set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup; a device token cannot dispatch tasks' : selectedMissing ? `this session's selected machine (${selectedName}) is not among the machines the relay currently holds, so a task narrowed to it has no target - pick another machine in the composer, or clear the selection` : result.error?.code === 'NO_ONLINE_DEVICE' ? 'no machine is currently streaming - start the agent on each machine, then w2m_devices shows who is online' : 'check the Rabbit log for the reason it gave'}`,
|
|
3285
3379
|
{
|
|
3286
3380
|
hint: operatorRequired
|
|
3287
3381
|
? 'set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup, or read it from `<state>/operator-token.txt`'
|
|
3288
|
-
:
|
|
3289
|
-
?
|
|
3290
|
-
:
|
|
3382
|
+
: selectedMissing
|
|
3383
|
+
? `the selected target was sent as ${selectionAppliedAs}=${selection?.machine_id ?? ''}; clear the composer selection to dispatch to the whole fleet again`
|
|
3384
|
+
: result.error?.code === 'NO_ONLINE_DEVICE'
|
|
3385
|
+
? 'no machine is currently streaming; start the agent on each machine (w2m_devices shows who is online)'
|
|
3386
|
+
: 'check the Rabbit log for the reason it gave',
|
|
3291
3387
|
},
|
|
3292
3388
|
);
|
|
3293
3389
|
}
|
|
@@ -3370,6 +3466,19 @@ export async function apply(ctx, config = {}) {
|
|
|
3370
3466
|
// v0.4.0: what the direct path tried, per machine. `mode` is repeated here rather than left
|
|
3371
3467
|
// to `w2m_status` so one dispatch's answer is self-contained.
|
|
3372
3468
|
direct_pushes: directPushes,
|
|
3469
|
+
// v0.6.1: whether this dispatch was narrowed to the composer's selected machine, and to
|
|
3470
|
+
// which one. Published because a dispatch that ran somewhere other than where the operator
|
|
3471
|
+
// pointed is the one failure this feature could hide, and a caller that cannot see the
|
|
3472
|
+
// routing cannot report it.
|
|
3473
|
+
selection: selection === null
|
|
3474
|
+
? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey }
|
|
3475
|
+
: {
|
|
3476
|
+
applied: selectionAppliedAs !== null,
|
|
3477
|
+
field: selectionAppliedAs,
|
|
3478
|
+
machine_id: selection.machine_id,
|
|
3479
|
+
machine_name: selection.machine_name,
|
|
3480
|
+
session: sessionKey,
|
|
3481
|
+
},
|
|
3373
3482
|
note: anchors.base_tree === null
|
|
3374
3483
|
? 'this machine\'s working tree could not be fingerprinted, so peers have no anchor to match and the aggregate will be unverifiable'
|
|
3375
3484
|
: 'peers are checked against base_commit + base_tree + command_hash',
|
|
@@ -3778,6 +3887,23 @@ export async function apply(ctx, config = {}) {
|
|
|
3778
3887
|
fingerprint_algo: 'git-temp-index-tree/v1',
|
|
3779
3888
|
error: anchors.error,
|
|
3780
3889
|
},
|
|
3890
|
+
/**
|
|
3891
|
+
* v0.6.1: the composer's target control and the selections it holds.
|
|
3892
|
+
*
|
|
3893
|
+
* Reported here because "the control is not in my composer" has exactly two causes — the
|
|
3894
|
+
* host has no fetch-route registry, or the browser half is not loaded — and this is the
|
|
3895
|
+
* one call that can tell them apart from the host side. The selections themselves are
|
|
3896
|
+
* counts, not contents: which session picked which machine is the operator's business and
|
|
3897
|
+
* nothing the model needs.
|
|
3898
|
+
*/
|
|
3899
|
+
target_selection: {
|
|
3900
|
+
route: TARGET_ROUTE_PATH,
|
|
3901
|
+
attached: targetRoutes.mounted,
|
|
3902
|
+
detail: targetRoutes.mounted
|
|
3903
|
+
? 'the composer control reads and writes its selection through this route'
|
|
3904
|
+
: targetRoutes.reason,
|
|
3905
|
+
sessions_with_a_target: defaultSelectionStore.list().length,
|
|
3906
|
+
},
|
|
3781
3907
|
notes: statusNotes(relay, rtt, cfg, p2pStatus),
|
|
3782
3908
|
},
|
|
3783
3909
|
null,
|