@twinsearth/w2m-dsh-plugin 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/lib/client.js +513 -0
- package/package.json +7 -3
- package/src/plugin/fleet-target.mjs +603 -0
- package/src/plugin/tools.mjs +149 -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.4';
|
|
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,32 @@ 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
|
+
const targetRoutes = registerTargetRoutes(ctx, {
|
|
2876
|
+
cfg: () => cfg,
|
|
2877
|
+
store: defaultSelectionStore,
|
|
2878
|
+
localIdentity,
|
|
2879
|
+
request,
|
|
2880
|
+
resolveToken,
|
|
2881
|
+
});
|
|
2882
|
+
if (targetRoutes.unexpected) {
|
|
2883
|
+
ctx.logger?.warn?.(`w2m: the composer target control is inactive — ${targetRoutes.reason}`);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2848
2886
|
/** Text output: rendered as JSON, because every one of these tools answers with a structure. */
|
|
2849
2887
|
const jsonOutput = {
|
|
2850
2888
|
schema: { type: 'string' },
|
|
@@ -2872,48 +2910,61 @@ export async function apply(ctx, config = {}) {
|
|
|
2872
2910
|
throwIfAborted(exec?.signal);
|
|
2873
2911
|
const base = requireRabbit(cfg);
|
|
2874
2912
|
const token = await resolveToken(cfg);
|
|
2875
|
-
|
|
2913
|
+
|
|
2914
|
+
// The read itself, the liveness rule, and "this machine has no identity yet" all come from
|
|
2915
|
+
// the same helper the composer control uses, so the list a model reads and the list an
|
|
2916
|
+
// operator clicks can never disagree about who is online.
|
|
2917
|
+
const identity = await localIdentity();
|
|
2918
|
+
const fleet = await readFleet({
|
|
2876
2919
|
rabbitUrl: base,
|
|
2877
2920
|
token,
|
|
2921
|
+
localMachineId: identity.machine_id,
|
|
2878
2922
|
signal: exec?.signal,
|
|
2879
|
-
|
|
2923
|
+
request,
|
|
2880
2924
|
});
|
|
2881
|
-
if (!
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2925
|
+
if (!fleet.ok) {
|
|
2926
|
+
// The typed error is rebuilt here, from the helper's report, so the code, message and hint a
|
|
2927
|
+
// caller sees are the same three it saw before this read moved into `fleet-target.mjs`: the
|
|
2928
|
+
// remediation travels in `hint`, never in the message.
|
|
2929
|
+
const unauthorized = fleet.status === 401;
|
|
2930
|
+
throw new W2MError(
|
|
2931
|
+
unauthorized ? 'W2M_RABBIT_REFUSED' : 'W2M_UNREACHABLE',
|
|
2932
|
+
fleet.status === 0
|
|
2933
|
+
? `${fleet.error}`
|
|
2934
|
+
: `${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'}`,
|
|
2935
|
+
{
|
|
2936
|
+
hint: fleet.hint ?? (unauthorized
|
|
2937
|
+
? 'this machine is not paired, or its device_token is stale; pair the agent again'
|
|
2938
|
+
: 'check the Rabbit log for the reason it gave'),
|
|
2939
|
+
},
|
|
2940
|
+
);
|
|
2887
2941
|
}
|
|
2888
2942
|
|
|
2889
2943
|
// `GET /v1/devices` answers `{protocol_version, rabbit_time, devices:[...]}`. Each entry is a
|
|
2890
2944
|
// 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 : []);
|
|
2945
|
+
// is described by identity and capabilities, and the relay's own `state` is the honest
|
|
2946
|
+
// default for a device it still lists.
|
|
2896
2947
|
const includeStale = args?.include_stale === true;
|
|
2897
|
-
const listed =
|
|
2948
|
+
const listed = fleet.records.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
|
|
2898
2949
|
|
|
2899
2950
|
return JSON.stringify(
|
|
2900
2951
|
{
|
|
2901
2952
|
ok: true,
|
|
2902
2953
|
protocol_version: PROTOCOL_VERSION,
|
|
2903
|
-
rabbit_time:
|
|
2954
|
+
rabbit_time: fleet.rabbit_time,
|
|
2904
2955
|
count: listed.length,
|
|
2905
2956
|
devices: listed.map((machine) => ({
|
|
2906
2957
|
machine_id: machine?.machine_id ?? null,
|
|
2907
2958
|
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,
|
|
2959
|
+
os: machine?.platform?.os ?? machine?.os ?? null,
|
|
2960
|
+
os_version: machine?.platform?.os_version ?? machine?.os_version ?? null,
|
|
2961
|
+
arch: machine?.platform?.arch ?? machine?.arch ?? null,
|
|
2962
|
+
shell: machine?.platform?.shell ?? machine?.shell ?? null,
|
|
2912
2963
|
caps: machine?.caps ?? null,
|
|
2913
2964
|
last_seen: machine?.last_seen ?? machine?.last_seen_at ?? null,
|
|
2914
2965
|
state: machine?.state ?? 'online',
|
|
2915
2966
|
})),
|
|
2916
|
-
excluded_stale: includeStale ? 0 :
|
|
2967
|
+
excluded_stale: includeStale ? 0 : fleet.records.length - listed.length,
|
|
2917
2968
|
note:
|
|
2918
2969
|
'a listed device has paired and is being tracked; it can still refuse the next command ' +
|
|
2919
2970
|
'at the capability gate, which w2m_wait reports per machine as `refused`',
|
|
@@ -2937,7 +2988,10 @@ export async function apply(ctx, config = {}) {
|
|
|
2937
2988
|
'Anchors (base_commit, base_tree) are computed here from this machine\'s working tree so ' +
|
|
2938
2989
|
'the peers can be checked against them. Under the default p2pMode the offer is also pushed ' +
|
|
2939
2990
|
'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.'
|
|
2991
|
+
'own offer remains the delivery when a push fails. ' +
|
|
2992
|
+
'A machine selected in the composer for this session narrows the dispatch to that machine; ' +
|
|
2993
|
+
'an explicit `executor_machine_id` always wins over it, and the answer\'s `selection` says ' +
|
|
2994
|
+
'which of the two routed the task.',
|
|
2941
2995
|
parameters: {
|
|
2942
2996
|
command_argv: {
|
|
2943
2997
|
type: 'array',
|
|
@@ -3160,6 +3214,34 @@ export async function apply(ctx, config = {}) {
|
|
|
3160
3214
|
);
|
|
3161
3215
|
}
|
|
3162
3216
|
|
|
3217
|
+
// -------------------------------------------------------------------------------------------
|
|
3218
|
+
// v0.6.1: the session's chosen target, when the caller named no machine of its own.
|
|
3219
|
+
// -------------------------------------------------------------------------------------------
|
|
3220
|
+
//
|
|
3221
|
+
// The composer control writes a selection into this process, keyed by session; here is where
|
|
3222
|
+
// it becomes real. Read before the payload literal so the routing facts and the answer agree.
|
|
3223
|
+
//
|
|
3224
|
+
// The precedence rule is the whole contract, and it is one line: **the caller's own fields
|
|
3225
|
+
// always win**. `executor_machine_id` is checked below, and `target_machines` is never a tool
|
|
3226
|
+
// argument — so a caller that named a machine (which, for this tool, means
|
|
3227
|
+
// `executor_machine_id` under broadcast) is untouched, and a caller that named none inherits
|
|
3228
|
+
// the selection. Nothing already published changes meaning: the selection only fills fields
|
|
3229
|
+
// that were absent, and the relay has accepted both of them since v0.3.3/v0.4.1.
|
|
3230
|
+
const sessionKey = sessionKeyFor(exec);
|
|
3231
|
+
const selection = defaultSelectionStore.get(sessionKey);
|
|
3232
|
+
const routedBy = dispatchSelectionFields(selection, mode);
|
|
3233
|
+
// broadcast reads the executor field; everything else reads `target_machines`. The relay
|
|
3234
|
+
// ignores the field a mode does not read, and the two are never both set by a selection.
|
|
3235
|
+
const selectionFields = mode === 'broadcast' && executorMachineId !== null
|
|
3236
|
+
? { fields: {}, applied_as: null }
|
|
3237
|
+
: routedBy.fields;
|
|
3238
|
+
// Whether the selection actually routed THIS task. Reported in the answer, because it is the
|
|
3239
|
+
// one thing an operator cannot recover afterwards: the payload is not in the transcript, and
|
|
3240
|
+
// the same command run twice can legitimately take two different routes.
|
|
3241
|
+
const selectionAppliedAs = mode === 'broadcast' && executorMachineId !== null
|
|
3242
|
+
? null
|
|
3243
|
+
: routedBy.applied_as;
|
|
3244
|
+
|
|
3163
3245
|
// Plugin-side allow-list check: only when one is configured. See checkAllowedCommand.
|
|
3164
3246
|
//
|
|
3165
3247
|
// **Every** stage is checked, not just the first. Checking only `command_argv` would let a
|
|
@@ -3256,6 +3338,10 @@ export async function apply(ctx, config = {}) {
|
|
|
3256
3338
|
// Only sent for broadcast; the relay ignores it otherwise, and the validation above already
|
|
3257
3339
|
// refused the combination, so this cannot silently mean nothing.
|
|
3258
3340
|
...(executorMachineId !== null ? { executor_machine_id: executorMachineId } : {}),
|
|
3341
|
+
// v0.6.1: the elected machine, when the caller did not name one and a mode that reads it is
|
|
3342
|
+
// in play. Spread after the caller's own field above so the caller always wins even if a
|
|
3343
|
+
// future edit makes both possible.
|
|
3344
|
+
...selectionFields,
|
|
3259
3345
|
// Only for pipeline. The relay ignores `stages` under the other modes and the validation
|
|
3260
3346
|
// above refuses the combination, so this can never silently mean nothing.
|
|
3261
3347
|
...(mode === 'pipeline' && stagesPayload !== null ? { stages: stagesPayload } : {}),
|
|
@@ -3279,15 +3365,22 @@ export async function apply(ctx, config = {}) {
|
|
|
3279
3365
|
// OPERATOR_REQUIRED gets its own guidance: it is the one refusal whose fix is a client-side
|
|
3280
3366
|
// setting rather than something on the relay.
|
|
3281
3367
|
const operatorRequired = result.error?.code === 'OPERATOR_REQUIRED' || result.status === 401;
|
|
3368
|
+
// v0.6.1: when a task was elected to one machine, NO_ONLINE_DEVICE is the *machine* being
|
|
3369
|
+
// unreachable, not the fleet being empty -- and the two have different fixes. Saying so is
|
|
3370
|
+
// the difference between "start an agent" and "the machine you picked went away".
|
|
3371
|
+
const selectedName = selection?.machine_name ?? selection?.machine_id ?? null;
|
|
3372
|
+
const selectedMissing = result.error?.code === 'NO_ONLINE_DEVICE' && selectionAppliedAs !== null;
|
|
3282
3373
|
throw new W2MError(
|
|
3283
3374
|
'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'}`,
|
|
3375
|
+
`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
3376
|
{
|
|
3286
3377
|
hint: operatorRequired
|
|
3287
3378
|
? '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
|
-
:
|
|
3379
|
+
: selectedMissing
|
|
3380
|
+
? `the selected target was sent as ${selectionAppliedAs}=${selection?.machine_id ?? ''}; clear the composer selection to dispatch to the whole fleet again`
|
|
3381
|
+
: result.error?.code === 'NO_ONLINE_DEVICE'
|
|
3382
|
+
? 'no machine is currently streaming; start the agent on each machine (w2m_devices shows who is online)'
|
|
3383
|
+
: 'check the Rabbit log for the reason it gave',
|
|
3291
3384
|
},
|
|
3292
3385
|
);
|
|
3293
3386
|
}
|
|
@@ -3370,6 +3463,19 @@ export async function apply(ctx, config = {}) {
|
|
|
3370
3463
|
// v0.4.0: what the direct path tried, per machine. `mode` is repeated here rather than left
|
|
3371
3464
|
// to `w2m_status` so one dispatch's answer is self-contained.
|
|
3372
3465
|
direct_pushes: directPushes,
|
|
3466
|
+
// v0.6.1: whether this dispatch was narrowed to the composer's selected machine, and to
|
|
3467
|
+
// which one. Published because a dispatch that ran somewhere other than where the operator
|
|
3468
|
+
// pointed is the one failure this feature could hide, and a caller that cannot see the
|
|
3469
|
+
// routing cannot report it.
|
|
3470
|
+
selection: selection === null
|
|
3471
|
+
? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey }
|
|
3472
|
+
: {
|
|
3473
|
+
applied: selectionAppliedAs !== null,
|
|
3474
|
+
field: selectionAppliedAs,
|
|
3475
|
+
machine_id: selection.machine_id,
|
|
3476
|
+
machine_name: selection.machine_name,
|
|
3477
|
+
session: sessionKey,
|
|
3478
|
+
},
|
|
3373
3479
|
note: anchors.base_tree === null
|
|
3374
3480
|
? 'this machine\'s working tree could not be fingerprinted, so peers have no anchor to match and the aggregate will be unverifiable'
|
|
3375
3481
|
: 'peers are checked against base_commit + base_tree + command_hash',
|
|
@@ -3778,6 +3884,23 @@ export async function apply(ctx, config = {}) {
|
|
|
3778
3884
|
fingerprint_algo: 'git-temp-index-tree/v1',
|
|
3779
3885
|
error: anchors.error,
|
|
3780
3886
|
},
|
|
3887
|
+
/**
|
|
3888
|
+
* v0.6.1: the composer's target control and the selections it holds.
|
|
3889
|
+
*
|
|
3890
|
+
* Reported here because "the control is not in my composer" has exactly two causes — the
|
|
3891
|
+
* host has no fetch-route registry, or the browser half is not loaded — and this is the
|
|
3892
|
+
* one call that can tell them apart from the host side. The selections themselves are
|
|
3893
|
+
* counts, not contents: which session picked which machine is the operator's business and
|
|
3894
|
+
* nothing the model needs.
|
|
3895
|
+
*/
|
|
3896
|
+
target_selection: {
|
|
3897
|
+
route: TARGET_ROUTE_PATH,
|
|
3898
|
+
attached: targetRoutes.mounted,
|
|
3899
|
+
detail: targetRoutes.mounted
|
|
3900
|
+
? 'the composer control reads and writes its selection through this route'
|
|
3901
|
+
: targetRoutes.reason,
|
|
3902
|
+
sessions_with_a_target: defaultSelectionStore.list().length,
|
|
3903
|
+
},
|
|
3781
3904
|
notes: statusNotes(relay, rtt, cfg, p2pStatus),
|
|
3782
3905
|
},
|
|
3783
3906
|
null,
|