diff --git a/docs/fleet/migration/v1-to-v2.md b/docs/fleet/migration/v1-to-v2.md index 18620a7..7d1a3ed 100644 --- a/docs/fleet/migration/v1-to-v2.md +++ b/docs/fleet/migration/v1-to-v2.md @@ -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 | diff --git a/packages/mosaic/src/fleet/v1-v2-migration.spec.ts b/packages/mosaic/src/fleet/v1-v2-migration.spec.ts index 747cffe..3adc255 100644 --- a/packages/mosaic/src/fleet/v1-v2-migration.spec.ts +++ b/packages/mosaic/src/fleet/v1-v2-migration.spec.ts @@ -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"], diff --git a/packages/mosaic/src/fleet/v1-v2-migration.ts b/packages/mosaic/src/fleet/v1-v2-migration.ts index 5e2e4c2..aa57dba 100644 --- a/packages/mosaic/src/fleet/v1-v2-migration.ts +++ b/packages/mosaic/src/fleet/v1-v2-migration.ts @@ -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'; }