@crvouga/dev-fleet 0.0.0-stage → 0.2.0
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/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
- package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
- package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
- package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
- package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
- package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
- package/LICENSE +21 -0
- package/README.md +357 -2
- package/agents/antigravity-proxy.ts +3 -0
- package/agents/audit.md +89 -0
- package/agents/claude-proxy +5 -0
- package/agents/claude-proxy.ts +3 -0
- package/agents/codex-proxy.ts +3 -0
- package/agents/grok-proxy.ts +3 -0
- package/agents/managed-proxy.ts +55 -0
- package/agents/opencode-proxy.ts +3 -0
- package/agents/registry.json +209 -0
- package/agents/registry.schema.json +149 -0
- package/agents/setup.md +158 -0
- package/agents/t3.json +40 -0
- package/ansible/macos/manager.yml +30 -0
- package/ansible/macos/policy.json +9 -0
- package/ansible/monitoring/README.md +215 -0
- package/ansible/monitoring/agent-darwin.yml +198 -0
- package/ansible/monitoring/agent.yml +172 -0
- package/ansible/monitoring/beszel-agent.service +42 -0
- package/ansible/monitoring/compose.yml +74 -0
- package/ansible/monitoring/hub-darwin.yml +102 -0
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +16 -0
- package/ansible/net/remote-probe.sh +13 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/authorize-sudo.sh +51 -0
- package/ansible/ubuntu/baseline.yml +89 -0
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/disk-guard.py +104 -0
- package/ansible/ubuntu/policy.json +52 -0
- package/ansible/ubuntu/recovery.yml +15 -0
- package/ansible/ubuntu/t3/policy.json +7 -0
- package/ansible/ubuntu/t3-desktop.yml +220 -0
- package/ansible/ubuntu/t3-health.sh +33 -0
- package/ansible/ubuntu/t3-policy.yml +76 -0
- package/ansible/ubuntu/t3-route.yml +28 -0
- package/ansible/ubuntu/tailscale.yml +78 -0
- package/ansible/ubuntu/tasks/credentials.yml +47 -0
- package/ansible/ubuntu/tasks/docker.yml +31 -0
- package/ansible/ubuntu/tasks/packages.yml +56 -0
- package/ansible/ubuntu/tasks/power.yml +51 -0
- package/ansible/ubuntu/tasks/recovery.yml +39 -0
- package/ansible/ubuntu/tasks/resources.yml +312 -0
- package/ansible/ubuntu/tasks/services.yml +31 -0
- package/ansible/ubuntu/tasks/ssh.yml +71 -0
- package/ansible/ubuntu/tasks/user.yml +120 -0
- package/ansible/uv.lock +190 -0
- package/bin/devfleet.mjs +36 -0
- package/bootstrap/bootstrap.ps1 +50 -0
- package/bootstrap/bootstrap.sh +80 -0
- package/bootstrap/devfleet +7 -0
- package/bootstrap/docker-desktop.sh +33 -0
- package/bootstrap/doctor.ts +234 -0
- package/bootstrap/fleet-manager-run.sh +103 -0
- package/bootstrap/fleet-remote-bootstrap.sh +258 -0
- package/bootstrap/global-config.ts +171 -0
- package/bootstrap/install-launcher.sh +10 -0
- package/bootstrap/native-aws.ts +86 -0
- package/bootstrap/native-t3.ts +154 -0
- package/bootstrap/npm.ts +358 -0
- package/bootstrap/release-version.ts +157 -0
- package/bootstrap/setup-status.ts +51 -0
- package/bootstrap/setup.ts +88 -0
- package/bootstrap/shell-path.ts +105 -0
- package/bootstrap/verify-t3-archive.py +49 -0
- package/bootstrap/worker-auth.ts +65 -0
- package/bootstrap/worker-github.ts +47 -0
- package/bootstrap/worker-tools.ts +24 -0
- package/cliproxy/AGENTS.md +11 -0
- package/cliproxy/README.md +269 -0
- package/cliproxy/clients/bootstrap.py +143 -0
- package/cliproxy/clients/launcher.py +57 -0
- package/cliproxy/compose.yaml +90 -0
- package/cliproxy/config.template.yaml +70 -0
- package/cliproxy/docs/cloud-adoption.md +16 -0
- package/cliproxy/docs/deployment.png +0 -0
- package/cliproxy/docs/deployment.svg +109 -0
- package/cliproxy/docs/health-workflow.example.yml +185 -0
- package/cliproxy/docs/migration.md +131 -0
- package/cliproxy/docs/model-health-report.schema.json +364 -0
- package/cliproxy/docs/model-health.md +393 -0
- package/cliproxy/image.lock.json +5 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
- package/cliproxy/models.json +1618 -0
- package/cliproxy/requirements.lock +78 -0
- package/cliproxy/scripts/accounts.py +38 -0
- package/cliproxy/scripts/audit.py +123 -0
- package/cliproxy/scripts/backup +234 -0
- package/cliproxy/scripts/check.py +34 -0
- package/cliproxy/scripts/checkpoint.py +226 -0
- package/cliproxy/scripts/cli.py +64 -0
- package/cliproxy/scripts/cliproxy_host.py +257 -0
- package/cliproxy/scripts/cloud_state.py +150 -0
- package/cliproxy/scripts/container-health +6 -0
- package/cliproxy/scripts/docker +24 -0
- package/cliproxy/scripts/external-health.py +79 -0
- package/cliproxy/scripts/fleet_config.py +43 -0
- package/cliproxy/scripts/fleet_sync.py +274 -0
- package/cliproxy/scripts/initialize +84 -0
- package/cliproxy/scripts/install-dependencies +48 -0
- package/cliproxy/scripts/install.py +406 -0
- package/cliproxy/scripts/model_health.py +16 -0
- package/cliproxy/scripts/modelhealth/__init__.py +5 -0
- package/cliproxy/scripts/modelhealth/checks.py +429 -0
- package/cliproxy/scripts/modelhealth/cli.py +117 -0
- package/cliproxy/scripts/modelhealth/config.py +186 -0
- package/cliproxy/scripts/modelhealth/emulator.py +536 -0
- package/cliproxy/scripts/modelhealth/expect.py +88 -0
- package/cliproxy/scripts/modelhealth/findings.py +183 -0
- package/cliproxy/scripts/modelhealth/protocol.py +330 -0
- package/cliproxy/scripts/modelhealth/redact.py +28 -0
- package/cliproxy/scripts/modelhealth/report.py +150 -0
- package/cliproxy/scripts/modelhealth/runner.py +390 -0
- package/cliproxy/scripts/modelhealth/scenario.py +131 -0
- package/cliproxy/scripts/modelhealth/schema.py +63 -0
- package/cliproxy/scripts/modelhealth/simulate.py +111 -0
- package/cliproxy/scripts/modelhealth/transport.py +213 -0
- package/cliproxy/scripts/monitor +68 -0
- package/cliproxy/scripts/ops +527 -0
- package/cliproxy/scripts/public-endpoint +127 -0
- package/cliproxy/scripts/recover_node.py +177 -0
- package/cliproxy/scripts/restore-verify +60 -0
- package/cliproxy/scripts/retention.py +144 -0
- package/cliproxy/scripts/service-start +7 -0
- package/cliproxy/scripts/validate-config +56 -0
- package/cliproxy/scripts/vault-access.py +57 -0
- package/cliproxy/scripts/vault-backup-login +55 -0
- package/cliproxy/scripts/vault-recovery +56 -0
- package/cliproxy/scripts/vault-renew +30 -0
- package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
- package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
- package/cliproxy/systemd/cliproxy-backup.service +13 -0
- package/cliproxy/systemd/cliproxy-backup.timer +8 -0
- package/cliproxy/systemd/cliproxy-fleet.service +17 -0
- package/cliproxy/systemd/cliproxy-gc.service +13 -0
- package/cliproxy/systemd/cliproxy-gc.timer +8 -0
- package/cliproxy/systemd/cliproxy-monitor.service +8 -0
- package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
- package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
- package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
- package/cliproxy/vendor/LICENSE +22 -0
- package/cliproxy/vendor/config.upstream.yaml +1349 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +89 -0
- package/docs/machine-setup.md +399 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +67 -0
- package/docs/ubuntu-sync.md +351 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +100 -0
- package/examples/roster.json +13 -0
- package/llms.txt +35 -0
- package/mise.lock +1521 -0
- package/mise.toml +110 -0
- package/package.json +74 -4
- package/schema/fleet.schema.json +420 -0
- package/schema/roster.schema.json +77 -0
- package/src/agents/account-relay.ts +111 -0
- package/src/agents/accounts.ts +85 -0
- package/src/agents/audit.ts +77 -0
- package/src/agents/cli.ts +13 -0
- package/src/agents/command.ts +204 -0
- package/src/agents/converge.ts +123 -0
- package/src/agents/doctor.ts +107 -0
- package/src/agents/errors.ts +2 -0
- package/src/agents/files.ts +73 -0
- package/src/agents/fleet-verify.ts +165 -0
- package/src/agents/launch.ts +290 -0
- package/src/agents/native-verify.ts +371 -0
- package/src/agents/provider-errors.ts +57 -0
- package/src/agents/registry.ts +61 -0
- package/src/agents/render.ts +110 -0
- package/src/agents/round-trip.ts +192 -0
- package/src/agents/secrets.ts +13 -0
- package/src/agents/toolchain.ts +34 -0
- package/src/agents/verify-cache.ts +103 -0
- package/src/agents/verify.ts +127 -0
- package/src/auth-checks.ts +163 -0
- package/src/cli/agent-guide.ts +129 -0
- package/src/cli/app.ts +122 -0
- package/src/cli/cli.ts +28 -0
- package/src/cli/command-index.ts +57 -0
- package/src/cli/contracts.ts +25 -0
- package/src/cli/fleet-reference.ts +17 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-checkout.ts +130 -0
- package/src/cli/fleet-sync-enrollment.ts +140 -0
- package/src/cli/fleet-sync-forward.ts +134 -0
- package/src/cli/fleet-sync-manager.ts +249 -0
- package/src/cli/fleet-sync-monitoring.ts +185 -0
- package/src/cli/fleet-sync-output.ts +317 -0
- package/src/cli/fleet-sync-privilege.ts +27 -0
- package/src/cli/fleet-sync-progress.ts +109 -0
- package/src/cli/fleet-sync-remote.ts +242 -0
- package/src/cli/fleet-sync-report.ts +26 -0
- package/src/cli/fleet-sync-schedule.ts +138 -0
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync-vault.ts +166 -0
- package/src/cli/fleet-sync.ts +375 -0
- package/src/cli/fleet.ts +165 -0
- package/src/cli/managed-health.ts +219 -0
- package/src/cli/node-deployment.ts +34 -0
- package/src/cli/node-sync.ts +121 -0
- package/src/cli/node-t3.ts +31 -0
- package/src/cli/options.ts +141 -0
- package/src/cli/probe-source.ts +7 -0
- package/src/cli/program.ts +185 -0
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +296 -0
- package/src/cli/registry.ts +185 -0
- package/src/cli/repair-apply.ts +67 -0
- package/src/cli/repair-guard.ts +11 -0
- package/src/cli/repair-inspect.ts +150 -0
- package/src/cli/repair-script.ts +76 -0
- package/src/cli/repair.ts +209 -0
- package/src/cli/roster.ts +105 -0
- package/src/cli/search.ts +35 -0
- package/src/cli/settings.ts +67 -0
- package/src/cli/source-operations.ts +102 -0
- package/src/cli/t3/command.ts +90 -0
- package/src/cli/t3/register.ts +55 -0
- package/src/cli/tailnet.ts +127 -0
- package/src/cli/ui-actions.ts +133 -0
- package/src/cli/ui-controller.ts +113 -0
- package/src/cli/ui-data.ts +106 -0
- package/src/cli/ui-execute.ts +163 -0
- package/src/cli/ui-repairs.ts +34 -0
- package/src/cli/ui-reports.ts +211 -0
- package/src/cli/ui-targets.ts +100 -0
- package/src/cli/ui-terminal.ts +109 -0
- package/src/cli/ui-types.ts +49 -0
- package/src/cli/ui-wizard.ts +101 -0
- package/src/cli/ui.ts +9 -0
- package/src/cli/worker-health-output.ts +37 -0
- package/src/config.ts +186 -0
- package/src/configure.ts +255 -0
- package/src/definition.ts +290 -0
- package/src/diagnostics.ts +92 -0
- package/src/doctor-check.ts +10 -0
- package/src/errors.ts +1 -0
- package/src/fingerprint.ts +120 -0
- package/src/fleet-config.ts +174 -0
- package/src/input-hashes.ts +73 -0
- package/src/machine/local-source.ts +52 -0
- package/src/machine/lock-error.ts +2 -0
- package/src/machine/platform.ts +44 -0
- package/src/machine/source.ts +68 -0
- package/src/machine/state.ts +173 -0
- package/src/machine-health.ts +95 -0
- package/src/managed-source.ts +47 -0
- package/src/manager-failure.ts +48 -0
- package/src/manager-sync.ts +405 -0
- package/src/manager.ts +62 -0
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +183 -0
- package/src/monitoring/beszel.ts +198 -0
- package/src/monitoring/enroll.ts +111 -0
- package/src/monitoring/hub-access.ts +114 -0
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +142 -0
- package/src/monitoring/hub.ts +93 -0
- package/src/monitoring/manager-agent.ts +73 -0
- package/src/monitoring/manager-hub.ts +248 -0
- package/src/monitoring/remote.ts +65 -0
- package/src/network-definition.ts +83 -0
- package/src/node-converge-linux.ts +374 -0
- package/src/node-converge-shared.ts +83 -0
- package/src/node-converge.ts +40 -0
- package/src/onboard.ts +35 -0
- package/src/package-info.ts +45 -0
- package/src/platform/ansible-receipt.ts +128 -0
- package/src/platform/ansible.ts +66 -0
- package/src/platform/darwin.ts +203 -0
- package/src/platform/detect.ts +11 -0
- package/src/platform/fallback.ts +110 -0
- package/src/platform/index.ts +38 -0
- package/src/platform/launchd.ts +98 -0
- package/src/platform/linux.ts +139 -0
- package/src/platform/ps.ts +45 -0
- package/src/platform/resource-verify.ts +132 -0
- package/src/platform/systemd-user.ts +95 -0
- package/src/platform/t3-policy.ts +137 -0
- package/src/platform/t3-route.ts +68 -0
- package/src/platform/t3-service.ts +75 -0
- package/src/platform/types.ts +123 -0
- package/src/platform/ubuntu-audit.ts +66 -0
- package/src/platform/ubuntu-verify.ts +105 -0
- package/src/platform/windows.ts +148 -0
- package/src/power-recovery.ts +55 -0
- package/src/probe.ts +51 -0
- package/src/protocol.ts +119 -0
- package/src/proxy-converge.ts +85 -0
- package/src/remote-bootstrap.ts +210 -0
- package/src/remote-entry.ts +89 -0
- package/src/remote-lock.ts +12 -0
- package/src/repair-catalog.ts +206 -0
- package/src/repair-plan.ts +78 -0
- package/src/repair-runner.ts +84 -0
- package/src/report.ts +71 -0
- package/src/runner.ts +104 -0
- package/src/secrets.ts +169 -0
- package/src/service-auth.ts +115 -0
- package/src/ssh.ts +135 -0
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/sync.ts +104 -0
- package/src/t3/accounts.ts +81 -0
- package/src/t3/desired.ts +165 -0
- package/src/t3/files.ts +81 -0
- package/src/t3/names-check.ts +53 -0
- package/src/t3/names.ts +155 -0
- package/src/t3/plan.ts +82 -0
- package/src/t3/providers.ts +74 -0
- package/src/t3/reconcile.ts +123 -0
- package/src/t3-endpoint.ts +104 -0
- package/src/t3-linked-check.ts +123 -0
- package/src/tailnet-health.ts +231 -0
- package/src/tailnet-matching.ts +37 -0
- package/src/tailscale.ts +141 -0
- package/src/toolchain-checks.ts +108 -0
- package/src/topology.ts +24 -0
- package/src/transport.ts +122 -0
- package/src/types.ts +99 -0
- package/src/vault-config.ts +38 -0
- package/src/worker-access-checks.ts +168 -0
- package/src/worker-capacity.ts +34 -0
- package/src/worker-host-checks.ts +181 -0
- package/src/worker-policy.ts +64 -0
- package/src/worker-readiness.ts +64 -0
- package/src/workstation-checks.ts +29 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fingerprint } from './fingerprint';
|
|
2
|
+
import { AUTOMATED_REPAIRS, recipe } from './repair-catalog';
|
|
3
|
+
import { syncWorkspace } from './sync';
|
|
4
|
+
import type { AutomatedRepair } from './repair-catalog';
|
|
5
|
+
import type { Check, Runner, Fingerprint } from './types';
|
|
6
|
+
|
|
7
|
+
export function validRepairs(value: unknown): value is AutomatedRepair[] {
|
|
8
|
+
return (
|
|
9
|
+
Array.isArray(value) &&
|
|
10
|
+
value.length > 0 &&
|
|
11
|
+
value.length <= 3 &&
|
|
12
|
+
new Set(value).size === value.length &&
|
|
13
|
+
value.every((id) => AUTOMATED_REPAIRS.includes(id as AutomatedRepair))
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Commands come exclusively from the allowlist, never from probe output or a roster. */
|
|
18
|
+
export async function repairWorkspace(
|
|
19
|
+
root: string,
|
|
20
|
+
repairs: AutomatedRepair[],
|
|
21
|
+
run: Runner,
|
|
22
|
+
expected?: Fingerprint
|
|
23
|
+
): Promise<Check[]> {
|
|
24
|
+
if (!validRepairs(repairs)) throw new Error('Unsupported repairs');
|
|
25
|
+
const before = await fingerprint(root, run);
|
|
26
|
+
if (!before.head || !Object.values(before.files).every(Boolean))
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
id: 'repair:workspace',
|
|
30
|
+
status: 'fail',
|
|
31
|
+
summary:
|
|
32
|
+
'Repairs require a complete existing checkout. Register/bootstrap it first.',
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
if (expected && JSON.stringify(before) !== JSON.stringify(expected))
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
id: 'repair:inputs',
|
|
39
|
+
status: 'fail',
|
|
40
|
+
summary:
|
|
41
|
+
'Checkout inputs changed since diagnostics. Generate a fresh repair plan before applying.',
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
const checks: Check[] = [];
|
|
45
|
+
for (const id of AUTOMATED_REPAIRS.filter((entry) =>
|
|
46
|
+
repairs.includes(entry)
|
|
47
|
+
)) {
|
|
48
|
+
const result =
|
|
49
|
+
id === 'workspace'
|
|
50
|
+
? await syncWorkspace(root, false, run)
|
|
51
|
+
: await ownerCommand(root, id, run);
|
|
52
|
+
checks.push(...result);
|
|
53
|
+
if (result.some((check) => check.status !== 'pass')) break;
|
|
54
|
+
}
|
|
55
|
+
const after = await fingerprint(root, run);
|
|
56
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
57
|
+
checks.push({
|
|
58
|
+
id: 'repair:inputs',
|
|
59
|
+
status: 'fail',
|
|
60
|
+
summary:
|
|
61
|
+
'Checkout inputs changed during repair. Stop and review the checkout before retrying.',
|
|
62
|
+
});
|
|
63
|
+
return checks;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function ownerCommand(
|
|
67
|
+
root: string,
|
|
68
|
+
id: AutomatedRepair,
|
|
69
|
+
run: Runner
|
|
70
|
+
): Promise<Check[]> {
|
|
71
|
+
const result = await run(recipe(id).commands[0]!, {
|
|
72
|
+
cwd: root,
|
|
73
|
+
timeoutMs: 1_200_000,
|
|
74
|
+
});
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
id: `repair:${id}`,
|
|
78
|
+
status: result.ok ? 'pass' : 'fail',
|
|
79
|
+
summary: result.ok
|
|
80
|
+
? 'The existing owner command completed. Fresh diagnostics determine whether configuration and authorization now pass.'
|
|
81
|
+
: `The owner command failed (${result.reason}); partial changes are possible. Raw output and credentials are withheld. Later repairs on this computer were stopped.`,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { NodeReport, Report, Status } from './types';
|
|
2
|
+
|
|
3
|
+
export function makeReport(command: string, nodes: NodeReport[]): Report {
|
|
4
|
+
const summary: Record<Status, number> = {
|
|
5
|
+
pass: 0,
|
|
6
|
+
warn: 0,
|
|
7
|
+
fail: 0,
|
|
8
|
+
skip: 0,
|
|
9
|
+
};
|
|
10
|
+
for (const node of nodes)
|
|
11
|
+
for (const check of node.checks) summary[check.status] += 1;
|
|
12
|
+
return {
|
|
13
|
+
version: 1,
|
|
14
|
+
command,
|
|
15
|
+
ok: summary.fail === 0,
|
|
16
|
+
generatedAt: new Date().toISOString(),
|
|
17
|
+
nodes,
|
|
18
|
+
summary,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function reportExitCode(report: Report, strict: boolean): number {
|
|
23
|
+
return !report.ok ||
|
|
24
|
+
(strict &&
|
|
25
|
+
report.nodes.some((node) =>
|
|
26
|
+
node.checks.some(
|
|
27
|
+
(check) =>
|
|
28
|
+
check.required !== false && ['warn', 'skip'].includes(check.status)
|
|
29
|
+
)
|
|
30
|
+
))
|
|
31
|
+
? 1
|
|
32
|
+
: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderReport(report: Report): string {
|
|
36
|
+
const lines = [
|
|
37
|
+
`Fleet ${report.command}: ${report.ok ? 'no failing checks' : 'attention needed'}`,
|
|
38
|
+
];
|
|
39
|
+
for (const node of report.nodes) {
|
|
40
|
+
lines.push(`\n${node.node}`);
|
|
41
|
+
for (const check of node.checks) {
|
|
42
|
+
lines.push(
|
|
43
|
+
` ${check.status.toUpperCase()} ${check.id}: ${check.summary}`
|
|
44
|
+
);
|
|
45
|
+
if (check.remedy) lines.push(` Next: ${check.remedy.action}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
lines.push(
|
|
49
|
+
`\n${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip`
|
|
50
|
+
);
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function mapBounded<Input, Output>(
|
|
55
|
+
items: Input[],
|
|
56
|
+
worker: (input: Input) => Promise<Output>,
|
|
57
|
+
concurrency = 4
|
|
58
|
+
): Promise<Output[]> {
|
|
59
|
+
const results = new Array<Output>(items.length);
|
|
60
|
+
let cursor = 0;
|
|
61
|
+
const consume = async (): Promise<void> => {
|
|
62
|
+
while (cursor < items.length) {
|
|
63
|
+
const index = cursor++;
|
|
64
|
+
results[index] = await worker(items[index]!);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
await Promise.all(
|
|
68
|
+
Array.from({ length: Math.min(concurrency, items.length) }, consume)
|
|
69
|
+
);
|
|
70
|
+
return results;
|
|
71
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { CommandOptions, CommandResult, Runner } from './types';
|
|
3
|
+
import type {
|
|
4
|
+
ChildProcess,
|
|
5
|
+
ChildProcessWithoutNullStreams,
|
|
6
|
+
StdioOptions,
|
|
7
|
+
} from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function terminate(child: ChildProcess): void {
|
|
12
|
+
try {
|
|
13
|
+
if (process.platform !== 'win32' && child.pid)
|
|
14
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
15
|
+
else child.kill('SIGKILL');
|
|
16
|
+
} catch {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runCommand(
|
|
20
|
+
command: string[],
|
|
21
|
+
options: CommandOptions = {}
|
|
22
|
+
): Promise<CommandResult> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const child = spawn(command[0]!, command.slice(1), {
|
|
25
|
+
cwd: options.cwd,
|
|
26
|
+
env: {
|
|
27
|
+
...(options.isolatedEnv ? {} : process.env),
|
|
28
|
+
...options.env,
|
|
29
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
30
|
+
CI: '1',
|
|
31
|
+
},
|
|
32
|
+
stdio: childStdio(options.preserveOperationLock),
|
|
33
|
+
windowsHide: true,
|
|
34
|
+
detached: process.platform !== 'win32',
|
|
35
|
+
}) as ChildProcessWithoutNullStreams;
|
|
36
|
+
let stdout = '';
|
|
37
|
+
let stderr = '';
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
let finished = false;
|
|
40
|
+
let reason: CommandResult['reason'] = 'exit';
|
|
41
|
+
const finish = (ok: boolean): void => {
|
|
42
|
+
if (finished) return;
|
|
43
|
+
finished = true;
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
child.stdin.destroy();
|
|
46
|
+
child.stdout.destroy();
|
|
47
|
+
child.stderr.destroy();
|
|
48
|
+
resolve({ ok, stdout, stderr, reason });
|
|
49
|
+
};
|
|
50
|
+
const abort = (cause: CommandResult['reason']): void => {
|
|
51
|
+
reason = cause;
|
|
52
|
+
terminate(child);
|
|
53
|
+
finish(false);
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
abort('timeout');
|
|
57
|
+
}, options.timeoutMs ?? 15_000);
|
|
58
|
+
const capture = (stream: 'stdout' | 'stderr', chunk: string): void => {
|
|
59
|
+
if (finished) return;
|
|
60
|
+
bytes += Buffer.byteLength(chunk);
|
|
61
|
+
if (bytes > MAX_OUTPUT_BYTES) {
|
|
62
|
+
abort('overflow');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (stream === 'stdout') stdout += chunk;
|
|
66
|
+
else stderr += chunk;
|
|
67
|
+
};
|
|
68
|
+
child.stdout
|
|
69
|
+
.setEncoding('utf8')
|
|
70
|
+
.on('data', (chunk: string) => capture('stdout', chunk));
|
|
71
|
+
child.stderr
|
|
72
|
+
.setEncoding('utf8')
|
|
73
|
+
.on('data', (chunk: string) => capture('stderr', chunk));
|
|
74
|
+
child.stdin.on('error', () => undefined);
|
|
75
|
+
child.on('error', () => {
|
|
76
|
+
abort('missing');
|
|
77
|
+
});
|
|
78
|
+
child.on('close', (code) => {
|
|
79
|
+
finish(code === 0 && reason === 'exit');
|
|
80
|
+
});
|
|
81
|
+
child.stdin.end(options.input);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Keep the shell-owned flock in descendants if their JS parent dies. */
|
|
86
|
+
export const runLockedCommand: Runner = (command, options = {}) =>
|
|
87
|
+
runCommand(command, { ...options, preserveOperationLock: true });
|
|
88
|
+
|
|
89
|
+
function childStdio(preserveLock: boolean | undefined): StdioOptions {
|
|
90
|
+
return preserveLock
|
|
91
|
+
? [
|
|
92
|
+
'pipe',
|
|
93
|
+
'pipe',
|
|
94
|
+
'pipe',
|
|
95
|
+
'ignore',
|
|
96
|
+
'ignore',
|
|
97
|
+
'ignore',
|
|
98
|
+
'ignore',
|
|
99
|
+
'ignore',
|
|
100
|
+
'ignore',
|
|
101
|
+
9,
|
|
102
|
+
]
|
|
103
|
+
: ['pipe', 'pipe', 'pipe'];
|
|
104
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** The secrets fleet code needs, by what they are for. Where each one is kept
|
|
7
|
+
* is the fleet's own choice, declared under `secrets` in its configuration.
|
|
8
|
+
*/
|
|
9
|
+
export const SECRET_NAMES = [
|
|
10
|
+
'proxy-client-key',
|
|
11
|
+
'monitoring-account-password',
|
|
12
|
+
'monitoring-tunnel-token',
|
|
13
|
+
'proxy-tunnel-token',
|
|
14
|
+
] as const;
|
|
15
|
+
export type SecretName = (typeof SECRET_NAMES)[number];
|
|
16
|
+
|
|
17
|
+
/** A secret's location in a Vault KV v2 mount. */
|
|
18
|
+
export type VaultLocation = { path: string; field: string };
|
|
19
|
+
export type SecretsConfig =
|
|
20
|
+
| { provider: 'env' }
|
|
21
|
+
| { provider: 'file'; directory?: string }
|
|
22
|
+
| { provider: 'sops'; file: string }
|
|
23
|
+
| {
|
|
24
|
+
provider: 'vault';
|
|
25
|
+
address: string;
|
|
26
|
+
mount: string;
|
|
27
|
+
locations: Record<SecretName, VaultLocation>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Runs a command and returns its stdout, or null when it fails. */
|
|
31
|
+
export type SecretCommand = (
|
|
32
|
+
argv: string[],
|
|
33
|
+
env?: Record<string, string>
|
|
34
|
+
) => Promise<string | null>;
|
|
35
|
+
|
|
36
|
+
/** One read of one secret for this node. Null when it is not available; the
|
|
37
|
+
* value is never logged, printed or placed in argv.
|
|
38
|
+
*/
|
|
39
|
+
export type SecretsProvider = (name: SecretName) => Promise<string | null>;
|
|
40
|
+
|
|
41
|
+
const present = (value: string | null | undefined): string | null =>
|
|
42
|
+
value?.trim() ? value.trim() : null;
|
|
43
|
+
|
|
44
|
+
/** `proxy-client-key` is read from `DEVFLEET_SECRET_PROXY_CLIENT_KEY`. */
|
|
45
|
+
export const envName = (name: SecretName): string =>
|
|
46
|
+
`DEVFLEET_SECRET_${name.toUpperCase().replaceAll('-', '_')}`;
|
|
47
|
+
|
|
48
|
+
export function secretsProvider(
|
|
49
|
+
config: SecretsConfig,
|
|
50
|
+
options: {
|
|
51
|
+
env?: NodeJS.ProcessEnv;
|
|
52
|
+
run?: SecretCommand;
|
|
53
|
+
home?: string;
|
|
54
|
+
} = {}
|
|
55
|
+
): SecretsProvider {
|
|
56
|
+
const env = options.env ?? process.env;
|
|
57
|
+
const run = options.run ?? execute;
|
|
58
|
+
if (config.provider === 'env')
|
|
59
|
+
return async (name) => present(env[envName(name)]);
|
|
60
|
+
if (config.provider === 'file') {
|
|
61
|
+
// One private file per secret, named after it.
|
|
62
|
+
const directory =
|
|
63
|
+
config.directory ??
|
|
64
|
+
join(options.home ?? homedir(), '.config/dev-fleet/secrets');
|
|
65
|
+
return async (name) =>
|
|
66
|
+
present(await readFile(join(directory, name), 'utf8').catch(() => null));
|
|
67
|
+
}
|
|
68
|
+
if (config.provider === 'sops')
|
|
69
|
+
// An age- (or KMS-) encrypted document with one top-level key per secret.
|
|
70
|
+
return async (name) =>
|
|
71
|
+
present(
|
|
72
|
+
await run([
|
|
73
|
+
'sops',
|
|
74
|
+
'--decrypt',
|
|
75
|
+
'--extract',
|
|
76
|
+
`["${name}"]`,
|
|
77
|
+
config.file,
|
|
78
|
+
])
|
|
79
|
+
);
|
|
80
|
+
return async (name) => {
|
|
81
|
+
const location = config.locations[name];
|
|
82
|
+
return present(
|
|
83
|
+
await run([
|
|
84
|
+
'vault',
|
|
85
|
+
'kv',
|
|
86
|
+
'get',
|
|
87
|
+
`-address=${config.address}`,
|
|
88
|
+
`-mount=${config.mount}`,
|
|
89
|
+
`-field=${location.field}`,
|
|
90
|
+
location.path,
|
|
91
|
+
])
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const execute: SecretCommand = (argv) =>
|
|
97
|
+
new Promise((resolve) => {
|
|
98
|
+
const child = spawn(argv[0]!, argv.slice(1), {
|
|
99
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
100
|
+
timeout: 20_000,
|
|
101
|
+
});
|
|
102
|
+
let output = '';
|
|
103
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
104
|
+
if (output.length < 65_536) output += chunk.toString('utf8');
|
|
105
|
+
});
|
|
106
|
+
child.on('error', () => resolve(null));
|
|
107
|
+
child.on('close', (code) => resolve(code === 0 ? output : null));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
type Fields = Record<string, unknown>;
|
|
111
|
+
const text = (value: unknown): value is string =>
|
|
112
|
+
typeof value === 'string' && value.length > 0 && value.length <= 512;
|
|
113
|
+
const only = (value: Fields, allowed: string[]) =>
|
|
114
|
+
Object.keys(value).every((key) => allowed.includes(key));
|
|
115
|
+
|
|
116
|
+
const PARSERS: Record<string, (value: Fields) => SecretsConfig | null> = {
|
|
117
|
+
env: (value) => (only(value, ['provider']) ? { provider: 'env' } : null),
|
|
118
|
+
file: (value) =>
|
|
119
|
+
only(value, ['provider', 'directory']) &&
|
|
120
|
+
(value['directory'] === undefined || text(value['directory']))
|
|
121
|
+
? {
|
|
122
|
+
provider: 'file',
|
|
123
|
+
...(text(value['directory'])
|
|
124
|
+
? { directory: value['directory'] }
|
|
125
|
+
: {}),
|
|
126
|
+
}
|
|
127
|
+
: null,
|
|
128
|
+
sops: (value) =>
|
|
129
|
+
only(value, ['provider', 'file']) && text(value['file'])
|
|
130
|
+
? { provider: 'sops', file: value['file'] }
|
|
131
|
+
: null,
|
|
132
|
+
vault: (value) => {
|
|
133
|
+
const { address, mount } = value;
|
|
134
|
+
const locations = vaultLocations(value['locations']);
|
|
135
|
+
return only(value, ['provider', 'address', 'mount', 'locations']) &&
|
|
136
|
+
text(address) &&
|
|
137
|
+
/^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(address) &&
|
|
138
|
+
text(mount) &&
|
|
139
|
+
locations
|
|
140
|
+
? { provider: 'vault', address, mount, locations }
|
|
141
|
+
: null;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
function vaultLocations(
|
|
146
|
+
value: unknown
|
|
147
|
+
): Record<SecretName, VaultLocation> | null {
|
|
148
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
149
|
+
const fields = value as Fields;
|
|
150
|
+
if (!only(fields, [...SECRET_NAMES])) return null;
|
|
151
|
+
const valid = SECRET_NAMES.every((name) => {
|
|
152
|
+
const location = fields[name] as Fields | undefined;
|
|
153
|
+
return (
|
|
154
|
+
location !== null &&
|
|
155
|
+
typeof location === 'object' &&
|
|
156
|
+
only(location, ['path', 'field']) &&
|
|
157
|
+
text(location['path']) &&
|
|
158
|
+
text(location['field'])
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
return valid ? (fields as Record<SecretName, VaultLocation>) : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The `secrets` block of a fleet configuration, or null when it is invalid. */
|
|
165
|
+
export function parseSecretsConfig(value: unknown): SecretsConfig | null {
|
|
166
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
167
|
+
const fields = value as Fields;
|
|
168
|
+
return PARSERS[String(fields['provider'])]?.(fields) ?? null;
|
|
169
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { record } from './config';
|
|
2
|
+
import type { Check, CommandResult, Runner } from './types';
|
|
3
|
+
|
|
4
|
+
interface Service {
|
|
5
|
+
id: string;
|
|
6
|
+
command: string[];
|
|
7
|
+
action: string;
|
|
8
|
+
valid: (result: CommandResult) => boolean;
|
|
9
|
+
}
|
|
10
|
+
function json(result: CommandResult): Record<string, unknown> | null {
|
|
11
|
+
try {
|
|
12
|
+
const data: unknown = JSON.parse(result.stdout);
|
|
13
|
+
return record(data) ? data : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function codingAuth(
|
|
19
|
+
id: 'codex' | 'claude',
|
|
20
|
+
run: Runner
|
|
21
|
+
): Promise<Check[]> {
|
|
22
|
+
return [
|
|
23
|
+
await serviceCheck(
|
|
24
|
+
id === 'codex'
|
|
25
|
+
? {
|
|
26
|
+
id,
|
|
27
|
+
command: ['codex', 'login', 'status'],
|
|
28
|
+
action: 'Run codex login on this computer as its operator.',
|
|
29
|
+
valid: (result) =>
|
|
30
|
+
/^Logged in using (ChatGPT|an API key|API key)\b/im.test(
|
|
31
|
+
`${result.stdout}\n${result.stderr}`
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
: {
|
|
35
|
+
id,
|
|
36
|
+
command: ['claude', 'auth', 'status', '--json'],
|
|
37
|
+
action: 'Run claude auth login on this computer as its operator.',
|
|
38
|
+
valid: (result) => json(result)?.['loggedIn'] === true,
|
|
39
|
+
},
|
|
40
|
+
run
|
|
41
|
+
),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function serviceAuth(run: Runner): Promise<Check[]> {
|
|
46
|
+
const services: Service[] = [
|
|
47
|
+
{
|
|
48
|
+
id: 'railway',
|
|
49
|
+
command: ['railway', 'whoami', '--json'],
|
|
50
|
+
action: 'Run railway login on this computer.',
|
|
51
|
+
valid: (result) => typeof json(result)?.['id'] === 'string',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'vercel',
|
|
55
|
+
command: ['vercel', 'whoami'],
|
|
56
|
+
action: 'Run vercel login on this computer.',
|
|
57
|
+
valid: (result) =>
|
|
58
|
+
/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(result.stdout.trim()),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'cloudflare',
|
|
62
|
+
command: ['wrangler', 'whoami', '--json'],
|
|
63
|
+
action: 'Run wrangler login on this computer.',
|
|
64
|
+
valid: (result) => json(result)?.['loggedIn'] === true,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'aws',
|
|
68
|
+
command: [
|
|
69
|
+
'aws',
|
|
70
|
+
'sts',
|
|
71
|
+
'get-caller-identity',
|
|
72
|
+
'--output',
|
|
73
|
+
'json',
|
|
74
|
+
'--no-cli-pager',
|
|
75
|
+
],
|
|
76
|
+
action:
|
|
77
|
+
'Select and authenticate the intended AWS profile on this computer.',
|
|
78
|
+
valid: (result) =>
|
|
79
|
+
typeof json(result)?.['Account'] === 'string' &&
|
|
80
|
+
typeof json(result)?.['Arn'] === 'string',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'npm',
|
|
84
|
+
command: ['npm', 'whoami', '--registry=https://registry.npmjs.org/'],
|
|
85
|
+
action: 'Run npm login only if this computer should publish packages.',
|
|
86
|
+
valid: (result) =>
|
|
87
|
+
/^[a-z0-9][a-z0-9._-]{0,213}$/.test(result.stdout.trim()),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
const checks: Check[] = [];
|
|
91
|
+
for (const service of services) checks.push(await serviceCheck(service, run));
|
|
92
|
+
for (const id of ['gemini', 'copilot', 'opencode'])
|
|
93
|
+
checks.push({
|
|
94
|
+
id: `auth:${id}`,
|
|
95
|
+
status: 'skip',
|
|
96
|
+
summary: `${id} provider authorization has no verified noninteractive status adapter in this CLI. Installed binaries or saved keys are not proof of access.`,
|
|
97
|
+
remedy: {
|
|
98
|
+
action: `Verify ${id} provider access locally using its supported account diagnostics. No prompt or billable request was made.`,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
return checks;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function serviceCheck(service: Service, run: Runner): Promise<Check> {
|
|
105
|
+
const result = await run(service.command, { timeoutMs: 15_000 });
|
|
106
|
+
const ok = result.ok && service.valid(result);
|
|
107
|
+
return {
|
|
108
|
+
id: `auth:${service.id}`,
|
|
109
|
+
status: ok ? 'pass' : 'fail',
|
|
110
|
+
summary: ok
|
|
111
|
+
? `${service.id} reports an authenticated session in this process context; no credential or account output retained.`
|
|
112
|
+
: `${service.id} login is missing or could not be verified (${result.reason}); no credential output retained.`,
|
|
113
|
+
...(!ok ? { remedy: { action: service.action } } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
package/src/ssh.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { CommandResult, FleetNode, Check } from './types';
|
|
2
|
+
|
|
3
|
+
export function shellQuote(value: string): string {
|
|
4
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function sshArgs(node: FleetNode): string[] {
|
|
8
|
+
return [
|
|
9
|
+
'ssh',
|
|
10
|
+
'-T',
|
|
11
|
+
'-a',
|
|
12
|
+
'-o',
|
|
13
|
+
'BatchMode=yes',
|
|
14
|
+
'-o',
|
|
15
|
+
'StrictHostKeyChecking=yes',
|
|
16
|
+
'-o',
|
|
17
|
+
'UpdateHostKeys=no',
|
|
18
|
+
'-o',
|
|
19
|
+
'ConnectTimeout=8',
|
|
20
|
+
'-o',
|
|
21
|
+
'ConnectionAttempts=1',
|
|
22
|
+
'-o',
|
|
23
|
+
'ServerAliveInterval=5',
|
|
24
|
+
'-o',
|
|
25
|
+
'ServerAliveCountMax=2',
|
|
26
|
+
'-o',
|
|
27
|
+
'ClearAllForwardings=yes',
|
|
28
|
+
'-o',
|
|
29
|
+
'PermitLocalCommand=no',
|
|
30
|
+
'-o',
|
|
31
|
+
'ControlMaster=no',
|
|
32
|
+
'-o',
|
|
33
|
+
'ControlPath=none',
|
|
34
|
+
node.ssh,
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function remoteCommand(node: FleetNode, request: object): string[] {
|
|
39
|
+
const encoded = Buffer.from(JSON.stringify(request)).toString('base64');
|
|
40
|
+
const command = `cd -- ${shellQuote(node.workspace)} && export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" && exec ${shellQuote(node.runtime ?? 'node')} --input-type=module - ${shellQuote(encoded)}`;
|
|
41
|
+
return [...sshArgs(node), command];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FAILURES = [
|
|
45
|
+
{
|
|
46
|
+
pattern:
|
|
47
|
+
/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|No .* host key is known/i,
|
|
48
|
+
id: 'ssh:host-key',
|
|
49
|
+
summary: 'SSH host key is unknown or changed.',
|
|
50
|
+
action:
|
|
51
|
+
'Verify the host fingerprint out of band, then update known_hosts manually. Never disable host-key checking.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
pattern:
|
|
55
|
+
/Could not resolve hostname|Name or service not known|nodename nor servname/i,
|
|
56
|
+
id: 'ssh:dns',
|
|
57
|
+
summary: 'SSH hostname or jump-host resolution failed.',
|
|
58
|
+
action:
|
|
59
|
+
'Check the alias in ~/.ssh/config, DNS/VPN/Tailscale connectivity, and ProxyJump host resolution.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
pattern:
|
|
63
|
+
/Permission denied|sign_and_send_pubkey|agent refused|Too many authentication failures/i,
|
|
64
|
+
id: 'ssh:auth',
|
|
65
|
+
summary: 'SSH key/agent authentication failed.',
|
|
66
|
+
action:
|
|
67
|
+
'Use ssh-add -l and ssh -vvv ALIAS locally; check User, IdentityFile, IdentitiesOnly, agent/keychain access and authorized_keys. Do not share debug logs without review.',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
pattern: /Connection refused|No route to host|Network is unreachable/i,
|
|
71
|
+
id: 'ssh:network',
|
|
72
|
+
summary: 'SSH endpoint or jump host is unreachable/refused.',
|
|
73
|
+
action:
|
|
74
|
+
'Verify the node is awake, VPN is connected, sshd is enabled and the configured port is reachable. Do not open a public SSH port automatically.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
pattern:
|
|
78
|
+
/(?:bun|node).*not found|No such file or directory|cannot cd|can.t cd/i,
|
|
79
|
+
id: 'ssh:runtime',
|
|
80
|
+
summary:
|
|
81
|
+
'Remote workspace or Node/Bun runtime is missing from the non-login SSH environment.',
|
|
82
|
+
action:
|
|
83
|
+
'Follow fleet-nodes --onboard; configure absolute workspace/runtime paths. GUI terminals and SSH shells may have different PATHs.',
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export function sshFailure(result: CommandResult): Check {
|
|
88
|
+
if (
|
|
89
|
+
/Tailscale SSH requires an additional check|To authenticate, visit: https:\/\/login\.tailscale\.com\//i.test(
|
|
90
|
+
result.stderr
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return {
|
|
94
|
+
id: 'ssh:tailscale-check',
|
|
95
|
+
status: 'fail',
|
|
96
|
+
summary: 'TAILSCALE_INTERACTIVE_AUTHORIZATION_REQUIRED',
|
|
97
|
+
remedy: {
|
|
98
|
+
action:
|
|
99
|
+
'The current Tailscale SSH policy requires interactive reauthentication. Update the tailnet policy for the enrolled manager/worker scope, or enroll a separately verified OpenSSH key route. Fleet never bypasses trust or prompts for a password.',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
if (result.reason === 'timeout')
|
|
103
|
+
return {
|
|
104
|
+
id: 'ssh:timeout',
|
|
105
|
+
status: 'fail',
|
|
106
|
+
summary: 'SSH or remote probe exceeded its deadline.',
|
|
107
|
+
remedy: {
|
|
108
|
+
action:
|
|
109
|
+
'Check sleeping nodes, VPN, ProxyJump, network reachability and noninteractive shell startup. Increase --timeout only after diagnosis.',
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
if (result.reason === 'missing')
|
|
113
|
+
return {
|
|
114
|
+
id: 'ssh:missing',
|
|
115
|
+
status: 'fail',
|
|
116
|
+
summary: 'Cannot launch the local SSH client.',
|
|
117
|
+
remedy: {
|
|
118
|
+
action:
|
|
119
|
+
'Install/enable the OS OpenSSH client and expose it to the terminal/editor PATH.',
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
const match = FAILURES.find((failure) => failure.pattern.test(result.stderr));
|
|
123
|
+
return {
|
|
124
|
+
id: match?.id ?? 'ssh:probe',
|
|
125
|
+
status: 'fail',
|
|
126
|
+
summary:
|
|
127
|
+
match?.summary ??
|
|
128
|
+
`Remote probe failed (${result.reason}); raw output withheld.`,
|
|
129
|
+
remedy: {
|
|
130
|
+
action:
|
|
131
|
+
match?.action ??
|
|
132
|
+
'Run ssh ALIAS manually; verify workspace/runtime, shell startup output and CLI compatibility. Never paste credentials or full logs into agent context.',
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|