@phnx-labs/agents-cli 1.20.64 → 1.20.66
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 +51 -3
- package/README.md +156 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.d.ts +12 -0
- package/dist/commands/apply.js +274 -0
- package/dist/commands/browser.js +2 -2
- package/dist/commands/cloud.js +32 -2
- package/dist/commands/doctor.js +4 -1
- package/dist/commands/exec.d.ts +48 -0
- package/dist/commands/exec.js +159 -49
- package/dist/commands/feed.js +25 -11
- package/dist/commands/hosts.js +44 -6
- package/dist/commands/mcp.js +55 -5
- package/dist/commands/monitors.d.ts +12 -0
- package/dist/commands/monitors.js +748 -0
- package/dist/commands/output.js +2 -2
- package/dist/commands/plugins.js +28 -7
- package/dist/commands/routines.js +23 -2
- package/dist/commands/secrets.d.ts +16 -0
- package/dist/commands/secrets.js +215 -64
- package/dist/commands/serve.js +31 -0
- package/dist/commands/sessions-browser.d.ts +82 -0
- package/dist/commands/sessions-browser.js +320 -0
- package/dist/commands/sessions-export.js +8 -3
- package/dist/commands/sessions.d.ts +17 -0
- package/dist/commands/sessions.js +157 -10
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +150 -0
- package/dist/commands/ssh.js +54 -3
- package/dist/commands/versions.js +7 -3
- package/dist/commands/view.d.ts +26 -0
- package/dist/commands/view.js +32 -9
- package/dist/commands/webhook.js +10 -2
- package/dist/index.js +35 -14
- package/dist/lib/agents.d.ts +46 -0
- package/dist/lib/agents.js +121 -3
- package/dist/lib/auto-dispatch-provider.js +7 -2
- package/dist/lib/auto-dispatch.d.ts +3 -0
- package/dist/lib/auto-dispatch.js +3 -0
- package/dist/lib/browser/chrome.js +2 -2
- package/dist/lib/cloud/antigravity.js +2 -2
- package/dist/lib/cloud/host.d.ts +59 -0
- package/dist/lib/cloud/host.js +224 -0
- package/dist/lib/cloud/registry.js +4 -0
- package/dist/lib/cloud/types.d.ts +6 -4
- package/dist/lib/computer-rpc.js +3 -1
- package/dist/lib/crabbox/cli.js +5 -1
- package/dist/lib/crabbox/runtimes.js +11 -2
- package/dist/lib/daemon.d.ts +20 -4
- package/dist/lib/daemon.js +62 -19
- package/dist/lib/devices/connect.d.ts +18 -1
- package/dist/lib/devices/connect.js +10 -2
- package/dist/lib/devices/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -0
- package/dist/lib/devices/known-hosts.d.ts +62 -0
- package/dist/lib/devices/known-hosts.js +137 -0
- package/dist/lib/devices/registry.d.ts +15 -0
- package/dist/lib/devices/registry.js +9 -0
- package/dist/lib/exec.d.ts +19 -2
- package/dist/lib/exec.js +41 -13
- package/dist/lib/fleet/apply.d.ts +63 -0
- package/dist/lib/fleet/apply.js +214 -0
- package/dist/lib/fleet/auth-sync.d.ts +67 -0
- package/dist/lib/fleet/auth-sync.js +142 -0
- package/dist/lib/fleet/manifest.d.ts +29 -0
- package/dist/lib/fleet/manifest.js +127 -0
- package/dist/lib/fleet/types.d.ts +129 -0
- package/dist/lib/fleet/types.js +13 -0
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +34 -2
- package/dist/lib/hosts/dispatch.d.ts +29 -8
- package/dist/lib/hosts/dispatch.js +66 -20
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/providers/devices.d.ts +27 -0
- package/dist/lib/hosts/providers/devices.js +98 -0
- package/dist/lib/hosts/registry.d.ts +10 -16
- package/dist/lib/hosts/registry.js +17 -50
- package/dist/lib/hosts/remote-cmd.d.ts +23 -0
- package/dist/lib/hosts/remote-cmd.js +79 -4
- package/dist/lib/hosts/run-target.d.ts +84 -0
- package/dist/lib/hosts/run-target.js +99 -0
- package/dist/lib/hosts/types.d.ts +23 -5
- package/dist/lib/hosts/types.js +22 -4
- package/dist/lib/linear-autoclose.d.ts +30 -0
- package/dist/lib/linear-autoclose.js +22 -0
- package/dist/lib/mcp.d.ts +27 -1
- package/dist/lib/mcp.js +126 -12
- package/dist/lib/monitors/config.d.ts +161 -0
- package/dist/lib/monitors/config.js +372 -0
- package/dist/lib/monitors/dispatch.d.ts +28 -0
- package/dist/lib/monitors/dispatch.js +91 -0
- package/dist/lib/monitors/engine.d.ts +61 -0
- package/dist/lib/monitors/engine.js +205 -0
- package/dist/lib/monitors/sources/command.d.ts +11 -0
- package/dist/lib/monitors/sources/command.js +31 -0
- package/dist/lib/monitors/sources/device.d.ts +13 -0
- package/dist/lib/monitors/sources/device.js +45 -0
- package/dist/lib/monitors/sources/file.d.ts +14 -0
- package/dist/lib/monitors/sources/file.js +57 -0
- package/dist/lib/monitors/sources/http.d.ts +10 -0
- package/dist/lib/monitors/sources/http.js +34 -0
- package/dist/lib/monitors/sources/index.d.ts +14 -0
- package/dist/lib/monitors/sources/index.js +31 -0
- package/dist/lib/monitors/sources/poll.d.ts +9 -0
- package/dist/lib/monitors/sources/poll.js +9 -0
- package/dist/lib/monitors/sources/types.d.ts +18 -0
- package/dist/lib/monitors/sources/types.js +9 -0
- package/dist/lib/monitors/sources/webhook.d.ts +23 -0
- package/dist/lib/monitors/sources/webhook.js +47 -0
- package/dist/lib/monitors/sources/ws.d.ts +14 -0
- package/dist/lib/monitors/sources/ws.js +45 -0
- package/dist/lib/monitors/state.d.ts +69 -0
- package/dist/lib/monitors/state.js +144 -0
- package/dist/lib/picker.d.ts +53 -0
- package/dist/lib/picker.js +214 -1
- package/dist/lib/platform/exec.d.ts +16 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/plugins.d.ts +31 -1
- package/dist/lib/plugins.js +175 -15
- package/dist/lib/redact.d.ts +14 -1
- package/dist/lib/redact.js +47 -1
- package/dist/lib/remote-agents-json.js +7 -1
- package/dist/lib/rotate.d.ts +6 -3
- package/dist/lib/rotate.js +0 -1
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +19 -0
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +102 -9
- package/dist/lib/secrets/agent.d.ts +83 -10
- package/dist/lib/secrets/agent.js +237 -70
- package/dist/lib/secrets/bundles.d.ts +26 -0
- package/dist/lib/secrets/bundles.js +59 -8
- package/dist/lib/secrets/filestore.d.ts +9 -0
- package/dist/lib/secrets/filestore.js +21 -8
- package/dist/lib/secrets/index.d.ts +7 -0
- package/dist/lib/secrets/index.js +26 -6
- package/dist/lib/secrets/mcp.js +4 -2
- package/dist/lib/secrets/remote.d.ts +17 -0
- package/dist/lib/secrets/remote.js +40 -0
- package/dist/lib/self-update.d.ts +20 -0
- package/dist/lib/self-update.js +54 -1
- package/dist/lib/serve/control.d.ts +95 -0
- package/dist/lib/serve/control.js +260 -0
- package/dist/lib/serve/server.d.ts +35 -1
- package/dist/lib/serve/server.js +106 -76
- package/dist/lib/serve/stream.d.ts +43 -0
- package/dist/lib/serve/stream.js +116 -0
- package/dist/lib/serve/token.d.ts +35 -0
- package/dist/lib/serve/token.js +85 -0
- package/dist/lib/session/bundle.d.ts +14 -0
- package/dist/lib/session/bundle.js +12 -1
- package/dist/lib/session/remote-list.js +5 -1
- package/dist/lib/session/state.d.ts +7 -25
- package/dist/lib/session/state.js +16 -6
- package/dist/lib/session/sync/config.js +8 -2
- package/dist/lib/session/types.d.ts +30 -0
- package/dist/lib/share/capture.d.ts +29 -0
- package/dist/lib/share/capture.js +140 -0
- package/dist/lib/share/config.d.ts +35 -0
- package/dist/lib/share/config.js +100 -0
- package/dist/lib/share/og.d.ts +25 -0
- package/dist/lib/share/og.js +84 -0
- package/dist/lib/share/provision.d.ts +10 -0
- package/dist/lib/share/provision.js +91 -0
- package/dist/lib/share/publish.d.ts +57 -0
- package/dist/lib/share/publish.js +145 -0
- package/dist/lib/share/worker-template.d.ts +2 -0
- package/dist/lib/share/worker-template.js +82 -0
- package/dist/lib/shims.d.ts +13 -0
- package/dist/lib/shims.js +42 -2
- package/dist/lib/ssh-exec.d.ts +24 -0
- package/dist/lib/ssh-exec.js +15 -3
- package/dist/lib/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +86 -7
- package/dist/lib/startup/command-registry.d.ts +3 -0
- package/dist/lib/startup/command-registry.js +6 -0
- package/dist/lib/state.d.ts +5 -0
- package/dist/lib/state.js +12 -0
- package/dist/lib/tmux/session.d.ts +7 -0
- package/dist/lib/tmux/session.js +3 -1
- package/dist/lib/triggers/webhook.d.ts +18 -0
- package/dist/lib/triggers/webhook.js +105 -0
- package/dist/lib/types.d.ts +36 -1
- package/dist/lib/usage.js +7 -5
- package/dist/lib/versions.js +14 -11
- package/dist/lib/workflows.d.ts +20 -0
- package/dist/lib/workflows.js +24 -0
- package/package.json +2 -1
package/dist/lib/plugins.js
CHANGED
|
@@ -134,12 +134,34 @@ export function pluginResourceGroups(plugin) {
|
|
|
134
134
|
out.push({ label: 'settings', items: ['settings.json'] });
|
|
135
135
|
return out;
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* True when a manifest field declares an inline execution surface — a non-empty
|
|
139
|
+
* path string, a non-empty array, or an object with at least one key. The
|
|
140
|
+
* official plugin format lets `hooks`/`mcpServers` live inline in the manifest
|
|
141
|
+
* (a path or an inline map) instead of as a `hooks/` dir or `.mcp.json` file, so
|
|
142
|
+
* filesystem-only detection would miss them and auto-enable a hostile plugin.
|
|
143
|
+
*/
|
|
144
|
+
function manifestDeclaresExecSurface(value) {
|
|
145
|
+
if (typeof value === 'string')
|
|
146
|
+
return value.trim().length > 0;
|
|
147
|
+
if (Array.isArray(value))
|
|
148
|
+
return value.length > 0;
|
|
149
|
+
if (value && typeof value === 'object')
|
|
150
|
+
return Object.keys(value).length > 0;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
137
153
|
export function inspectPluginCapabilities(pluginRoot) {
|
|
138
154
|
const manifest = loadPluginManifest(pluginRoot);
|
|
139
155
|
const plugin = manifest ? buildDiscoveredPlugin(pluginRoot, manifest) : null;
|
|
140
156
|
return {
|
|
141
|
-
|
|
142
|
-
|
|
157
|
+
// Inline manifest `hooks`/`mcpServers` are execution surfaces too — a cloned
|
|
158
|
+
// repo's project plugin must not be auto-enabled just because it ships the
|
|
159
|
+
// exec config inline in plugin.json rather than as a hooks/ dir or .mcp.json.
|
|
160
|
+
hasHooks: (plugin?.hooks.length || 0) > 0 ||
|
|
161
|
+
pluginHasDirectoryEntries(pluginRoot, 'hooks') ||
|
|
162
|
+
manifestDeclaresExecSurface(manifest?.hooks),
|
|
163
|
+
hasMcp: fs.existsSync(path.join(pluginRoot, '.mcp.json')) ||
|
|
164
|
+
manifestDeclaresExecSurface(manifest?.mcpServers),
|
|
143
165
|
hasBin: (plugin?.bin.length || 0) > 0,
|
|
144
166
|
hasScripts: (plugin?.scripts.length || 0) > 0,
|
|
145
167
|
hasSettings: pluginHasNonPermissionSettings(pluginRoot),
|
|
@@ -499,6 +521,14 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
|
|
|
499
521
|
// OpenCode uses TS/JS modules under ~/.config/opencode/plugins/, not the
|
|
500
522
|
// Claude marketplace layout. Install those modules and return early.
|
|
501
523
|
if (agent === 'opencode') {
|
|
524
|
+
// Trust gate (RUSH-1756): OpenCode plugins are raw executable TS/JS modules,
|
|
525
|
+
// so they must clear the same consent check as every other exec surface
|
|
526
|
+
// before install — this branch used to return early, bypassing the gate the
|
|
527
|
+
// Gemini/Hermes/marketplace branches all apply.
|
|
528
|
+
const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
|
|
529
|
+
if (!enablePlugin) {
|
|
530
|
+
return result;
|
|
531
|
+
}
|
|
502
532
|
const ok = installOpenCodePlugin(plugin, versionHome);
|
|
503
533
|
result.success = ok;
|
|
504
534
|
if (ok)
|
|
@@ -982,6 +1012,72 @@ function writeGeminiExtensionManifest(plugin, destRoot) {
|
|
|
982
1012
|
}
|
|
983
1013
|
fs.writeFileSync(path.join(destRoot, GEMINI_EXTENSION_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
|
|
984
1014
|
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Security (RUSH-1755): `fs.cpSync(..., { recursive: true })` copies symlinks
|
|
1017
|
+
* verbatim (dereference defaults to false), so a malicious plugin can ship a
|
|
1018
|
+
* symlink whose target escapes the install root — e.g.
|
|
1019
|
+
* `.agents-cli-managed -> ~/.bashrc`. The managed-marker / manifest writes that
|
|
1020
|
+
* follow the copy would then write THROUGH the link, clobbering an
|
|
1021
|
+
* attacker-chosen path outside the install root.
|
|
1022
|
+
*
|
|
1023
|
+
* Walk destRoot after the recursive copy, lstat each entry, and remove any
|
|
1024
|
+
* symlink whose resolved target escapes BOTH destRoot and sourceRoot. Both roots
|
|
1025
|
+
* matter because Node's cpSync rewrites a *relative* internal symlink
|
|
1026
|
+
* (`./x`) into an *absolute* link back into the source tree, so a legitimate
|
|
1027
|
+
* internal symlink resolves under sourceRoot (not destRoot) after the copy.
|
|
1028
|
+
* Keeping targets within sourceRoot preserves those internal symlinks — matching
|
|
1029
|
+
* copyPluginToMarketplace's policy — while genuinely external escapes are
|
|
1030
|
+
* dropped, neutralizing the write-through.
|
|
1031
|
+
*/
|
|
1032
|
+
function stripEscapingSymlinks(destRoot, sourceRoot) {
|
|
1033
|
+
const realRoots = [destRoot, sourceRoot].map((r) => {
|
|
1034
|
+
try {
|
|
1035
|
+
return fs.realpathSync(r);
|
|
1036
|
+
}
|
|
1037
|
+
catch {
|
|
1038
|
+
return r;
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
const within = (target) => realRoots.some((root) => target === root || target.startsWith(root + path.sep));
|
|
1042
|
+
const removed = [];
|
|
1043
|
+
const walk = (dir) => {
|
|
1044
|
+
let entries;
|
|
1045
|
+
try {
|
|
1046
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
for (const entry of entries) {
|
|
1052
|
+
const full = path.join(dir, entry.name);
|
|
1053
|
+
if (entry.isSymbolicLink()) {
|
|
1054
|
+
let escapes;
|
|
1055
|
+
try {
|
|
1056
|
+
escapes = !within(fs.realpathSync(full));
|
|
1057
|
+
}
|
|
1058
|
+
catch {
|
|
1059
|
+
// Dangling / unresolvable symlink — treat as escaping and drop it.
|
|
1060
|
+
escapes = true;
|
|
1061
|
+
}
|
|
1062
|
+
if (escapes) {
|
|
1063
|
+
try {
|
|
1064
|
+
fs.rmSync(full, { force: true });
|
|
1065
|
+
removed.push(path.relative(destRoot, full) || entry.name);
|
|
1066
|
+
}
|
|
1067
|
+
catch { /* best effort */ }
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
else if (entry.isDirectory()) {
|
|
1071
|
+
// Do not descend into symlinked dirs — isDirectory() is false for a
|
|
1072
|
+
// symlink even when it points at a directory, so this only recurses
|
|
1073
|
+
// into real subdirectories, keeping the walk inside destRoot.
|
|
1074
|
+
walk(full);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
walk(destRoot);
|
|
1079
|
+
return removed;
|
|
1080
|
+
}
|
|
985
1081
|
export function installGeminiPlugin(plugin, versionHome) {
|
|
986
1082
|
const destRoot = path.join(geminiExtensionsDir(versionHome), plugin.name);
|
|
987
1083
|
try {
|
|
@@ -989,6 +1085,7 @@ export function installGeminiPlugin(plugin, versionHome) {
|
|
|
989
1085
|
fs.rmSync(destRoot, { recursive: true, force: true });
|
|
990
1086
|
}
|
|
991
1087
|
fs.cpSync(plugin.root, destRoot, { recursive: true });
|
|
1088
|
+
stripEscapingSymlinks(destRoot, plugin.root);
|
|
992
1089
|
const userConfig = loadUserConfig(plugin.name);
|
|
993
1090
|
if (Object.keys(userConfig).length > 0) {
|
|
994
1091
|
expandUserConfigInDir(destRoot, userConfig);
|
|
@@ -1030,6 +1127,7 @@ export function installGoosePlugin(plugin, versionHome) {
|
|
|
1030
1127
|
fs.rmSync(destRoot, { recursive: true, force: true });
|
|
1031
1128
|
}
|
|
1032
1129
|
fs.cpSync(plugin.root, destRoot, { recursive: true });
|
|
1130
|
+
stripEscapingSymlinks(destRoot, plugin.root);
|
|
1033
1131
|
fs.writeFileSync(path.join(destRoot, '.agents-cli-managed'), `plugin=${plugin.name}\n`, 'utf-8');
|
|
1034
1132
|
return true;
|
|
1035
1133
|
}
|
|
@@ -1111,6 +1209,7 @@ export function installHermesPlugin(plugin, versionHome, enable) {
|
|
|
1111
1209
|
fs.rmSync(destRoot, { recursive: true, force: true });
|
|
1112
1210
|
}
|
|
1113
1211
|
fs.cpSync(plugin.root, destRoot, { recursive: true });
|
|
1212
|
+
stripEscapingSymlinks(destRoot, plugin.root);
|
|
1114
1213
|
const userConfig = loadUserConfig(plugin.name);
|
|
1115
1214
|
if (Object.keys(userConfig).length > 0) {
|
|
1116
1215
|
expandUserConfigInDir(destRoot, userConfig);
|
|
@@ -1640,7 +1739,33 @@ export function getUpstreamManifestVersion(info) {
|
|
|
1640
1739
|
* Update an installed plugin by re-pulling from its original source.
|
|
1641
1740
|
* Returns true if the update succeeded.
|
|
1642
1741
|
*/
|
|
1643
|
-
|
|
1742
|
+
/**
|
|
1743
|
+
* Labels of exec surfaces present in `after` that were NOT present in `before`.
|
|
1744
|
+
* Used by updatePlugin to distinguish a newly-appearing execution surface
|
|
1745
|
+
* (upstream compromise → renewed consent required) from one the user already
|
|
1746
|
+
* trusted (leave enablement alone).
|
|
1747
|
+
*/
|
|
1748
|
+
export function newExecSurfaceLabels(before, after) {
|
|
1749
|
+
return Object.keys(PLUGIN_EXEC_SURFACE_LABELS)
|
|
1750
|
+
.filter((key) => after[key] && !before[key])
|
|
1751
|
+
.map((key) => PLUGIN_EXEC_SURFACE_LABELS[key]);
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Re-fetch a plugin from its recorded source and apply the update to disk.
|
|
1755
|
+
*
|
|
1756
|
+
* Security (RUSH-1757): a plugin's upstream is mutable. `updatePlugin` never
|
|
1757
|
+
* mutates the live plugin tree before it has inspected the incoming content —
|
|
1758
|
+
* the new revision is fetched into a **quarantine** dir first, its capabilities
|
|
1759
|
+
* are diffed against the current on-disk baseline, and the update is applied to
|
|
1760
|
+
* `plugin.root` only after the trust decision. If the update introduces a NEW
|
|
1761
|
+
* executable surface (hooks/, .mcp.json, bin/, scripts/, settings.json,
|
|
1762
|
+
* permissions/) that the current revision did not carry, the update is refused
|
|
1763
|
+
* unless `options.allowExecSurfaces` is set — the last-good content is kept in
|
|
1764
|
+
* place, so a benign-then-compromised upstream can never execute on the next
|
|
1765
|
+
* update without renewed consent. A surface the user already trusted is not a
|
|
1766
|
+
* "new" surface and does not re-trigger the gate.
|
|
1767
|
+
*/
|
|
1768
|
+
export async function updatePlugin(name, options = {}) {
|
|
1644
1769
|
const plugin = getPlugin(name);
|
|
1645
1770
|
if (!plugin) {
|
|
1646
1771
|
return { success: false, error: `Plugin '${name}' not found` };
|
|
@@ -1656,33 +1781,68 @@ export async function updatePlugin(name) {
|
|
|
1656
1781
|
catch {
|
|
1657
1782
|
return { success: false, error: `Could not read source info for '${name}'` };
|
|
1658
1783
|
}
|
|
1784
|
+
// Baseline: what the live (already-trusted) revision ships today.
|
|
1785
|
+
const before = inspectPluginCapabilities(plugin.root);
|
|
1786
|
+
// Quarantine dir lives beside plugin.root (same filesystem) so the final
|
|
1787
|
+
// apply can be a rename. The dot-prefix keeps it out of plugin discovery.
|
|
1788
|
+
const quarantine = path.join(path.dirname(plugin.root), `.${path.basename(plugin.root)}.update-quarantine`);
|
|
1789
|
+
const cleanupQuarantine = () => {
|
|
1790
|
+
try {
|
|
1791
|
+
fs.rmSync(quarantine, { recursive: true, force: true });
|
|
1792
|
+
}
|
|
1793
|
+
catch { /* best effort */ }
|
|
1794
|
+
};
|
|
1795
|
+
cleanupQuarantine();
|
|
1659
1796
|
try {
|
|
1797
|
+
// 1. Fetch the incoming revision into quarantine — never touch plugin.root yet.
|
|
1660
1798
|
if (sourceInfo.isGit) {
|
|
1661
|
-
|
|
1799
|
+
// Copy the working checkout (with its .git) and fast-forward the copy, so a
|
|
1800
|
+
// hostile upstream diff lands in the quarantine, not the live tree.
|
|
1801
|
+
fs.cpSync(plugin.root, quarantine, { recursive: true });
|
|
1802
|
+
execFileSync('git', ['-C', quarantine, 'pull', '--ff-only'], { stdio: 'pipe' });
|
|
1662
1803
|
}
|
|
1663
1804
|
else {
|
|
1664
1805
|
const resolvedSource = sourceInfo.source.replace(/^~/, homeDir());
|
|
1665
1806
|
if (!fs.existsSync(resolvedSource)) {
|
|
1807
|
+
cleanupQuarantine();
|
|
1666
1808
|
return { success: false, error: `Source path no longer exists: ${resolvedSource}` };
|
|
1667
1809
|
}
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1810
|
+
fs.cpSync(resolvedSource, quarantine, { recursive: true });
|
|
1811
|
+
}
|
|
1812
|
+
// 2. Diff capabilities of the incoming revision against the baseline.
|
|
1813
|
+
const after = inspectPluginCapabilities(quarantine);
|
|
1814
|
+
const newSurfaces = newExecSurfaceLabels(before, after);
|
|
1815
|
+
// 3. Refuse a surface-introducing update without renewed consent. The
|
|
1816
|
+
// last-good content stays in place untouched.
|
|
1817
|
+
if (newSurfaces.length > 0 && options.allowExecSurfaces !== true) {
|
|
1818
|
+
cleanupQuarantine();
|
|
1819
|
+
return {
|
|
1820
|
+
success: false,
|
|
1821
|
+
blockedByExecSurfaces: true,
|
|
1822
|
+
newExecSurfaces: newSurfaces,
|
|
1823
|
+
error: `Update refused: '${name}' introduces new executable surfaces (${newSurfaces.join(', ')}). ` +
|
|
1824
|
+
`Re-run with --allow-exec-surfaces if you trust the source.`,
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
// 4. Apply: swap the quarantined revision into plugin.root, preserving the
|
|
1828
|
+
// user config and re-stamping .source.
|
|
1829
|
+
const userConfigPath = path.join(plugin.root, USER_CONFIG_FILE);
|
|
1830
|
+
const userConfigBackup = fs.existsSync(userConfigPath)
|
|
1831
|
+
? fs.readFileSync(userConfigPath, 'utf-8')
|
|
1832
|
+
: null;
|
|
1833
|
+
fs.rmSync(plugin.root, { recursive: true, force: true });
|
|
1834
|
+
fs.renameSync(quarantine, plugin.root);
|
|
1835
|
+
if (userConfigBackup !== null) {
|
|
1836
|
+
fs.writeFileSync(userConfigPath, userConfigBackup, 'utf-8');
|
|
1678
1837
|
}
|
|
1679
1838
|
// Re-stamp .source with the freshly pulled manifest version so the baseline
|
|
1680
1839
|
// tracks what's now on disk (keeps the heal "unmodified?" check honest).
|
|
1681
1840
|
const freshVersion = loadPluginManifest(plugin.root)?.version;
|
|
1682
1841
|
fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify({ ...sourceInfo, version: freshVersion }), 'utf-8');
|
|
1842
|
+
return { success: true, newExecSurfaces: newSurfaces, hasExecSurfaces: hasPluginExecSurfaces(after) };
|
|
1683
1843
|
}
|
|
1684
1844
|
catch (err) {
|
|
1845
|
+
cleanupQuarantine();
|
|
1685
1846
|
return { success: false, error: err.message };
|
|
1686
1847
|
}
|
|
1687
|
-
return { success: true };
|
|
1688
1848
|
}
|
package/dist/lib/redact.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared redaction helpers for text that may be exported or logged.
|
|
3
3
|
*/
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Scrub secrets from `text`. Two passes: format-based patterns (above), then a
|
|
6
|
+
* value-aware pass that masks any `knownValues` verbatim — a credential we
|
|
7
|
+
* already hold in hand leaks regardless of its format, so an exact-value match
|
|
8
|
+
* catches tokens the regexes don't recognize.
|
|
9
|
+
*/
|
|
10
|
+
export declare function redactSecrets(text: string, knownValues?: readonly string[]): string;
|
|
11
|
+
/**
|
|
12
|
+
* Secret values already present in the environment (e.g. an injected secrets
|
|
13
|
+
* bundle), selected by secret-shaped var NAME. These are the "known" values fed
|
|
14
|
+
* to {@link redactSecrets} so an exported transcript can't leak a live
|
|
15
|
+
* credential verbatim even when its format matches no pattern.
|
|
16
|
+
*/
|
|
17
|
+
export declare function knownSecretValuesFromEnv(env?: NodeJS.ProcessEnv): string[];
|
package/dist/lib/redact.js
CHANGED
|
@@ -3,17 +3,63 @@
|
|
|
3
3
|
*/
|
|
4
4
|
const SECRET_PATTERNS = [
|
|
5
5
|
[/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED_AWS_KEY]'],
|
|
6
|
+
// GitHub: classic PATs (ghp_), OAuth (gho_), app/refresh/server tokens
|
|
7
|
+
// (ghs_/ghr_), and fine-grained PATs (github_pat_). All share the 36-char
|
|
8
|
+
// classic body; fine-grained tokens are longer, so match greedily.
|
|
6
9
|
[/\bghp_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
|
|
10
|
+
[/\bgh[osru]_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
|
|
11
|
+
[/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[REDACTED_GITHUB_TOKEN]'],
|
|
12
|
+
// Anthropic keys (sk-ant-api03-…) before the generic sk- rule so the marker
|
|
13
|
+
// is specific; the generic rule would otherwise swallow it first.
|
|
14
|
+
[/\bsk-ant-api03-[A-Za-z0-9_-]{20,}\b/g, '[REDACTED_ANTHROPIC_KEY]'],
|
|
15
|
+
// Stripe live secret / restricted keys.
|
|
16
|
+
[/\b[rs]k_live_[A-Za-z0-9]{20,}\b/g, '[REDACTED_STRIPE_KEY]'],
|
|
7
17
|
[/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
|
|
18
|
+
// Slack bot/user/app-level tokens (xoxb-/xoxp-/xapp-…).
|
|
19
|
+
[/\bxox[bp]-[A-Za-z0-9-]{10,}\b/g, '[REDACTED_SLACK_TOKEN]'],
|
|
20
|
+
[/\bxapp-[A-Za-z0-9-]{10,}\b/g, '[REDACTED_SLACK_TOKEN]'],
|
|
8
21
|
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
|
|
9
22
|
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
|
|
10
23
|
[/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
|
|
11
24
|
[/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
|
|
12
25
|
];
|
|
13
|
-
|
|
26
|
+
/** Env vars whose NAME marks their VALUE as a credential worth masking literally. */
|
|
27
|
+
const SECRET_ENV_NAME = /(?:TOKEN|KEY|SECRET|PASSWORD)/i;
|
|
28
|
+
/** Don't literal-mask trivially short values — they collide with ordinary text. */
|
|
29
|
+
const MIN_KNOWN_VALUE_LEN = 6;
|
|
30
|
+
/**
|
|
31
|
+
* Scrub secrets from `text`. Two passes: format-based patterns (above), then a
|
|
32
|
+
* value-aware pass that masks any `knownValues` verbatim — a credential we
|
|
33
|
+
* already hold in hand leaks regardless of its format, so an exact-value match
|
|
34
|
+
* catches tokens the regexes don't recognize.
|
|
35
|
+
*/
|
|
36
|
+
export function redactSecrets(text, knownValues) {
|
|
14
37
|
let safe = text;
|
|
15
38
|
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
|
16
39
|
safe = safe.replace(pattern, replacement);
|
|
17
40
|
}
|
|
41
|
+
if (knownValues) {
|
|
42
|
+
for (const value of knownValues) {
|
|
43
|
+
if (value.length < MIN_KNOWN_VALUE_LEN)
|
|
44
|
+
continue;
|
|
45
|
+
safe = safe.split(value).join('[REDACTED]');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
18
48
|
return safe;
|
|
19
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Secret values already present in the environment (e.g. an injected secrets
|
|
52
|
+
* bundle), selected by secret-shaped var NAME. These are the "known" values fed
|
|
53
|
+
* to {@link redactSecrets} so an exported transcript can't leak a live
|
|
54
|
+
* credential verbatim even when its format matches no pattern.
|
|
55
|
+
*/
|
|
56
|
+
export function knownSecretValuesFromEnv(env = process.env) {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const [name, value] of Object.entries(env)) {
|
|
59
|
+
if (!value || value.length < MIN_KNOWN_VALUE_LEN)
|
|
60
|
+
continue;
|
|
61
|
+
if (SECRET_ENV_NAME.test(name))
|
|
62
|
+
out.push(value);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -11,7 +11,7 @@ import chalk from 'chalk';
|
|
|
11
11
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from './ssh-exec.js';
|
|
12
12
|
import { sshTargetFor } from './devices/connect.js';
|
|
13
13
|
import { resolveExplicitTargets } from './devices/resolve-target.js';
|
|
14
|
-
import { loadDevices } from './devices/registry.js';
|
|
14
|
+
import { loadDevices, isControlDevice } from './devices/registry.js';
|
|
15
15
|
import { remoteShellFor, buildWindowsAgentsCommand } from './hosts/remote-cmd.js';
|
|
16
16
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
17
17
|
const REMOTE_TIMEOUT_MS = 12_000;
|
|
@@ -66,6 +66,12 @@ export async function gatherRemoteAgentsJson(options) {
|
|
|
66
66
|
continue;
|
|
67
67
|
if (normalizeHost(device.name) === self)
|
|
68
68
|
continue;
|
|
69
|
+
// Control-only devices (a phone/tablet cockpit) drive the fleet but never
|
|
70
|
+
// run agents — never dial them, whatever their platform reads as. Keyed on
|
|
71
|
+
// role, not platform, so this holds even for a control device that carries
|
|
72
|
+
// a real OS value (mirrors the skip in session/remote-list.ts).
|
|
73
|
+
if (isControlDevice(device))
|
|
74
|
+
continue;
|
|
69
75
|
if (!['windows', 'linux', 'macos'].includes(device.platform))
|
|
70
76
|
continue;
|
|
71
77
|
try {
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -182,18 +182,21 @@ export declare function rotationFailoverChain(rotation: RotateResult | null, pic
|
|
|
182
182
|
* would break a previously-working `agents run … --loop` / `--acp`.
|
|
183
183
|
* - `resumeCheckpoint` runs take the loop path (same guard).
|
|
184
184
|
* - `interactive` / no-prompt runs can't be re-dispatched headlessly.
|
|
185
|
-
* - `explicitFallback` (a user `--fallback` chain OR a profile fallback already
|
|
186
|
-
* unshifted) defines its own recovery; don't layer rotation failover on top.
|
|
187
185
|
* - `hasRotation`/`hasVersion` gate on an actual pre-flight rotation having
|
|
188
186
|
* picked an account, so pinned and non-rotation runs are untouched.
|
|
189
187
|
*
|
|
188
|
+
* An explicit `--fallback` chain does NOT disarm rotation failover: the
|
|
189
|
+
* synthesized same-agent entries are unshifted AHEAD of the user's cross-agent
|
|
190
|
+
* entries, so a rate limit exhausts the other accounts of the same agent
|
|
191
|
+
* before cascading to a different CLI. Profile fallbacks never reach here —
|
|
192
|
+
* strategy resolution is skipped for profiles, so hasRotation is false.
|
|
193
|
+
*
|
|
190
194
|
* Pure so the arming matrix is unit-testable without invoking the run command.
|
|
191
195
|
*/
|
|
192
196
|
export interface FailoverArmingContext {
|
|
193
197
|
hasRotation: boolean;
|
|
194
198
|
hasVersion: boolean;
|
|
195
199
|
hasPrompt: boolean;
|
|
196
|
-
explicitFallback: boolean;
|
|
197
200
|
interactive: boolean;
|
|
198
201
|
acp: boolean;
|
|
199
202
|
loop: boolean;
|
package/dist/lib/rotate.js
CHANGED
package/dist/lib/routines.d.ts
CHANGED
|
@@ -91,6 +91,17 @@ export interface JobConfig {
|
|
|
91
91
|
* overdue; everywhere else it is inert and `run` refuses with a pointer.
|
|
92
92
|
*/
|
|
93
93
|
devices?: string[];
|
|
94
|
+
/**
|
|
95
|
+
* Execution placement — run the job body on this machine over SSH (a
|
|
96
|
+
* registered host, device, capability tag, or user@host) instead of locally.
|
|
97
|
+
* Distinct from `devices`: `devices` says which daemon may FIRE the job,
|
|
98
|
+
* `host` says where the dispatched run EXECUTES. CLI flag: `--run-on`
|
|
99
|
+
* (`--host` on routines commands already means "manage routines on that
|
|
100
|
+
* machine" via the remote passthrough).
|
|
101
|
+
*/
|
|
102
|
+
host?: string;
|
|
103
|
+
/** Working directory on the host for `host:`-placed runs. */
|
|
104
|
+
remoteCwd?: string;
|
|
94
105
|
variables?: Record<string, string>;
|
|
95
106
|
sandbox?: boolean;
|
|
96
107
|
allow?: JobAllowConfig;
|
|
@@ -123,6 +134,11 @@ export interface RunMeta {
|
|
|
123
134
|
startedAt: string;
|
|
124
135
|
completedAt: string | null;
|
|
125
136
|
exitCode: number | null;
|
|
137
|
+
/** Set for `host:`-placed runs — where the job body executes (no local pid). */
|
|
138
|
+
host?: string;
|
|
139
|
+
/** The host-task sidecar id backing a `host:` run; the daemon monitor
|
|
140
|
+
* finalizes the run by reconciling it against the remote `.exit`. */
|
|
141
|
+
hostTaskId?: string;
|
|
126
142
|
}
|
|
127
143
|
/**
|
|
128
144
|
* True when the job may execute on this machine: no `devices` allowlist (or
|
package/dist/lib/routines.js
CHANGED
|
@@ -363,6 +363,25 @@ export function validateJob(config) {
|
|
|
363
363
|
if (config.device !== undefined) {
|
|
364
364
|
errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
|
|
365
365
|
}
|
|
366
|
+
if (config.host !== undefined) {
|
|
367
|
+
if (typeof config.host !== 'string' || config.host.trim() === '') {
|
|
368
|
+
errors.push('host must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
|
|
369
|
+
}
|
|
370
|
+
// v1: the workflow bundle and the loop driver (with its signal files) live
|
|
371
|
+
// on the firing machine — neither can cross SSH to the target yet.
|
|
372
|
+
if (config.workflow) {
|
|
373
|
+
errors.push("host: can't be combined with workflow: yet (the bundle lives on the firing machine) — run the workflow locally or convert it to a plain prompt");
|
|
374
|
+
}
|
|
375
|
+
if (config.loop) {
|
|
376
|
+
errors.push("host: can't be combined with loop: yet (the loop driver and its signal files live on the firing machine)");
|
|
377
|
+
}
|
|
378
|
+
if (config.command) {
|
|
379
|
+
errors.push("host: can't be combined with command: yet (a plain shell command has no agent to place remotely) — run it locally, or convert it to a prompt");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (config.remoteCwd !== undefined && config.host === undefined) {
|
|
383
|
+
errors.push('remoteCwd only applies to host:-placed routines — set host: too, or drop it');
|
|
384
|
+
}
|
|
366
385
|
if (config.devices !== undefined) {
|
|
367
386
|
if (!Array.isArray(config.devices)) {
|
|
368
387
|
errors.push('devices must be an array of device names (as shown by `agents devices`)');
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -77,6 +77,7 @@ export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, ag
|
|
|
77
77
|
*/
|
|
78
78
|
export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
|
|
79
79
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
80
|
+
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
80
81
|
export declare function executeJobDetached(config: JobConfig): Promise<RunMeta>;
|
|
81
82
|
/** Extract the final assistant message from a stream-JSON log file as a markdown report. */
|
|
82
83
|
export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
|
package/dist/lib/runner.js
CHANGED
|
@@ -23,6 +23,8 @@ import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
|
|
|
23
23
|
import { resolveModel, buildReasoningFlags } from './models.js';
|
|
24
24
|
import { createTimer, maybeRotate, redactPrompt } from './events.js';
|
|
25
25
|
import { normalizeMode, buildExecEnv, detectRateLimit, } from './exec.js';
|
|
26
|
+
import { loadTask as loadHostTask } from './hosts/tasks.js';
|
|
27
|
+
import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
|
|
26
28
|
import { backgroundSpawnOptions } from './platform/process.js';
|
|
27
29
|
import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
|
|
28
30
|
import { getConfiguredRunStrategy, resolveRunVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
|
|
@@ -448,6 +450,12 @@ export async function executeJob(config, deps) {
|
|
|
448
450
|
if (eligibility) {
|
|
449
451
|
throw new Error(eligibility.message);
|
|
450
452
|
}
|
|
453
|
+
// `host:` placement — the job body runs on another machine over SSH; local
|
|
454
|
+
// version selection / sandbox / spawn do not apply. Sync callers (manual
|
|
455
|
+
// `routines run`, catchup) follow the remote run to completion.
|
|
456
|
+
if (config.host) {
|
|
457
|
+
return executeJobOnHost(config, { detached: false });
|
|
458
|
+
}
|
|
451
459
|
// Command-mode: run a plain shell command directly (no agent, no rotation,
|
|
452
460
|
// no pinning, no sandbox overlay). Reuses the run-record machinery so
|
|
453
461
|
// list/runs/overdue keep working.
|
|
@@ -617,15 +625,67 @@ export async function executeJob(config, deps) {
|
|
|
617
625
|
timer.end({ status: 'failed', exitCode: 1, runId });
|
|
618
626
|
return { meta, reportPath: null };
|
|
619
627
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
628
|
+
async function executeJobOnHost(config, opts) {
|
|
629
|
+
if (config.workflow) {
|
|
630
|
+
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
|
|
631
|
+
}
|
|
632
|
+
if (config.loop) {
|
|
633
|
+
throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute on a host yet — remove 'host:' or 'loop:'.`);
|
|
634
|
+
}
|
|
635
|
+
if (config.command) {
|
|
636
|
+
throw new Error(`Routine '${config.name}' uses 'command:', which can't execute on a host yet — remove 'host:' or 'command:'.`);
|
|
637
|
+
}
|
|
638
|
+
const { resolveHostRunTarget, dispatchPromptToHost } = await import('./hosts/run-target.js');
|
|
639
|
+
const host = await resolveHostRunTarget(config.host);
|
|
640
|
+
const timer = createTimer('agent.run', {
|
|
641
|
+
agent: config.agent,
|
|
642
|
+
jobName: config.name,
|
|
643
|
+
mode: config.mode,
|
|
644
|
+
host: host.name,
|
|
645
|
+
...redactPrompt(config.prompt),
|
|
646
|
+
schedule: config.schedule,
|
|
647
|
+
});
|
|
648
|
+
const runId = generateRunId();
|
|
649
|
+
const runDir = getRunDir(config.name, runId);
|
|
650
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
651
|
+
const meta = {
|
|
652
|
+
jobName: config.name,
|
|
653
|
+
runId,
|
|
654
|
+
agent: config.agent,
|
|
655
|
+
pid: null, // no local process — the run lives on the host
|
|
656
|
+
spawnedAt: Date.now(),
|
|
657
|
+
status: 'running',
|
|
658
|
+
startedAt: new Date().toISOString(),
|
|
659
|
+
completedAt: null,
|
|
660
|
+
exitCode: null,
|
|
661
|
+
host: host.name,
|
|
662
|
+
};
|
|
663
|
+
writeRunMeta(meta);
|
|
664
|
+
const { task, exitCode } = await dispatchPromptToHost(host, {
|
|
665
|
+
agent: config.agent,
|
|
666
|
+
prompt: resolveJobPrompt(config),
|
|
667
|
+
mode: normalizeMode(config.mode),
|
|
668
|
+
effort: config.effort,
|
|
669
|
+
model: config.config?.model,
|
|
670
|
+
timeout: config.timeout, // enforced by the REMOTE agents run
|
|
671
|
+
remoteCwd: config.remoteCwd,
|
|
672
|
+
name: config.name,
|
|
673
|
+
cwd: runDir,
|
|
674
|
+
follow: !opts.detached,
|
|
675
|
+
});
|
|
676
|
+
meta.hostTaskId = task.id;
|
|
677
|
+
// Sync path: a real exit code finalizes now. -1 (follow window closed) and
|
|
678
|
+
// the detached path leave the meta `running` for the monitor to reconcile.
|
|
679
|
+
if (!opts.detached && exitCode !== null && exitCode !== undefined && exitCode !== -1) {
|
|
680
|
+
meta.status = exitCode === 0 ? 'completed' : 'failed';
|
|
681
|
+
meta.exitCode = exitCode;
|
|
682
|
+
meta.completedAt = new Date().toISOString();
|
|
683
|
+
}
|
|
684
|
+
writeRunMeta(meta);
|
|
685
|
+
timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
|
|
686
|
+
return { meta, reportPath: null };
|
|
687
|
+
}
|
|
688
|
+
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
629
689
|
async function executeCommandJobForeground(config) {
|
|
630
690
|
const timer = createTimer('agent.run', {
|
|
631
691
|
jobName: config.name,
|
|
@@ -712,12 +772,18 @@ async function executeCommandJobForeground(config) {
|
|
|
712
772
|
return { meta, reportPath: null };
|
|
713
773
|
}
|
|
714
774
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
775
|
+
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
715
776
|
export async function executeJobDetached(config) {
|
|
716
777
|
const eligibility = checkJobDeviceEligibility(config);
|
|
717
778
|
if (eligibility) {
|
|
718
779
|
process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
|
|
719
780
|
throw new Error(eligibility.message);
|
|
720
781
|
}
|
|
782
|
+
// `host:` placement — dispatch over SSH and return; the monitor finalizes.
|
|
783
|
+
if (config.host) {
|
|
784
|
+
const { meta } = await executeJobOnHost(config, { detached: true });
|
|
785
|
+
return meta;
|
|
786
|
+
}
|
|
721
787
|
// Command-mode: fire a plain shell command detached (no agent, no rotation,
|
|
722
788
|
// no pinning, no sandbox overlay). Still writes a run record so the daemon,
|
|
723
789
|
// list/runs, and overdue tracking keep working.
|
|
@@ -994,6 +1060,26 @@ function isPidOurs(pid, spawnedAt) {
|
|
|
994
1060
|
return true;
|
|
995
1061
|
}
|
|
996
1062
|
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Finalize one `host:`-placed run by healing its host-task sidecar against the
|
|
1065
|
+
* remote `.exit` (lib/hosts/reconcile.ts). Mutates + persists the meta only
|
|
1066
|
+
* when the sidecar reached a terminal state.
|
|
1067
|
+
*/
|
|
1068
|
+
function finalizeHostRun(meta) {
|
|
1069
|
+
try {
|
|
1070
|
+
const task = loadHostTask(meta.hostTaskId);
|
|
1071
|
+
if (!task)
|
|
1072
|
+
return;
|
|
1073
|
+
const healed = reconcileHostTask(task);
|
|
1074
|
+
if (healed.status !== 'completed' && healed.status !== 'failed')
|
|
1075
|
+
return;
|
|
1076
|
+
meta.status = healed.status;
|
|
1077
|
+
meta.exitCode = healed.exitCode ?? (healed.status === 'completed' ? 0 : 1);
|
|
1078
|
+
meta.completedAt = healed.finishedAt ?? new Date().toISOString();
|
|
1079
|
+
writeRunMeta(meta);
|
|
1080
|
+
}
|
|
1081
|
+
catch { /* unreachable host or unreadable sidecar — retry next sweep */ }
|
|
1082
|
+
}
|
|
997
1083
|
/** Scan all runs marked "running" and finalize any whose process has exited. */
|
|
998
1084
|
export function monitorRunningJobs() {
|
|
999
1085
|
const runsDir = getRunsDir();
|
|
@@ -1013,6 +1099,13 @@ export function monitorRunningJobs() {
|
|
|
1013
1099
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
1014
1100
|
if (meta.status !== 'running')
|
|
1015
1101
|
continue;
|
|
1102
|
+
// `host:`-placed run — no local pid to watch. Reconcile against the
|
|
1103
|
+
// remote `.exit` (completion is confirmed, never guessed: an
|
|
1104
|
+
// unreachable host leaves the run `running` for the next sweep).
|
|
1105
|
+
if (meta.hostTaskId) {
|
|
1106
|
+
finalizeHostRun(meta);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1016
1109
|
if (!meta.pid)
|
|
1017
1110
|
continue;
|
|
1018
1111
|
const runDirPath = path.join(jobRunsPath, runDirEntry.name);
|