fix(fleet): align migration SSH parity docs
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-07-16 06:13:03 -05:00
parent 66ac59af49
commit fce489269a
3 changed files with 43 additions and 7 deletions

View File

@@ -22,10 +22,11 @@ path value. These request-shape failures are reported before any input file is r
observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local
agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever
v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model,
reasoning, enabled state, and launch policy. A supported socket field declared by v1 remains authoritative,
including an explicit empty value representing the default tmux server; a matching `socketName` decision is
accepted, an incompatible decision blocks, and a decision supplies the socket only when both supported v1
socket fields are genuinely absent. If v1 omitted `tool_policy`, decisions must supply an
reasoning, enabled state, and launch policy. The v1 source remains authoritative for socket semantics:
a supported declared socket field, including an explicit empty value for the default tmux server, is
preserved; if both supported root aliases are absent, the production v1 default is the literal empty socket.
A matching `socketName` decision is accepted and an incompatible decision blocks, but a decision never
supplies or repairs a missing source socket. If v1 omitted `tool_policy`, decisions must supply an
explicit replacement; it is never derived from `class`. `model_hint` is never split or treated as
authority.
@@ -50,7 +51,7 @@ be marked disabled. Observed-stopped agents always remain stopped.
| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference |
| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation |
| `kickstart_template` | No v2 field; explicit inventory-only disposition required |
| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition |
| root `connector` | Inventory-only; never contacted or reconciled |
| unknown fields or snake/camel synonym collisions | Inventoried and block readiness |

View File

@@ -352,6 +352,41 @@ describe('v1-to-v2 migration preview', (): void => {
},
);
it('matches production v1 locality for a remote agent with a bare-host SSH target', async () => {
const source = v1Source.replace('ssh: operator@build.example.test', 'ssh: build.example.test');
const rosterPath = join(
await mkdtemp(join(tmpdir(), 'v1-bare-host-ssh-parity-')),
'roster.yaml',
);
temporaryDirectories.push(dirname(rosterPath));
await writeFile(rosterPath, source);
await expect(loadFleetRoster(rosterPath)).resolves.toMatchObject({
agents: [
expect.any(Object),
expect.objectContaining({ host: 'build.example.test', ssh: 'build.example.test' }),
],
});
const preview = await previewV1ToV2Migration({
source,
decisions: decisions(),
observations: observations(),
personaDirs: await semanticDirs(),
});
expect(preview.status).toBe('ready');
expect(preview.blockers).toEqual([]);
expect(preview.inventoryOnlyAgents).toEqual([
{
name: 'remote0',
fields: ['host', 'socket', 'ssh'],
disposition: 'inventory-only',
},
]);
expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
});
it.each([
['socket_name', " socket_name: ''\n holder_session: _holder"],
['socketName', " socketName: ''\n holder_session: _holder"],

View File

@@ -778,8 +778,8 @@ function classifyAgentLocality(
if (hasSsh) {
if (!ssh) return 'ambiguous';
const separator = ssh.lastIndexOf('@');
const sshHost = separator >= 0 ? ssh.slice(separator + 1) : undefined;
if (!sshHost || sshHost !== host) return 'ambiguous';
const sshHost = separator >= 0 ? ssh.slice(separator + 1) : ssh;
if (sshHost !== host) return 'ambiguous';
}
return host === fleetHost ? 'local' : 'remote';
}