Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion client/src/lib/ports.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@
* top of that. Use these instead of re-hardcoding a port literal in a form
* default, a copy-paste help string, or a cross-machine URL.
*/
export { PORTS, DEFAULT_PEER_PORT } from '../../../server/lib/ports.js';
export {
PORTS,
DEFAULT_PEER_PORT,
DEFAULT_TAILCAT_LOCAL_PORT,
DEFAULT_TAILCAT_REMOTE_PORT,
} from '../../../server/lib/ports.js';
86 changes: 83 additions & 3 deletions client/src/pages/Instances.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import Pill from '../components/ui/Pill';
import EmptyState from '../components/EmptyState';
import socket from '../services/socket';
import {
getInstances, updateSelfInstance, addPeer, updatePeer,
getInstances, updateSelfInstance, addPeer, addTailcatPeer, updatePeer,
removePeer, connectPeer, reciprocatePeer, probePeer, syncPeer, getTailnetInfo,
getNetworkExposure,
listPeerSubscriptions,
Expand All @@ -24,7 +24,7 @@ import {
import PeerAppsList from '../components/instances/PeerAppsList';
import PeerAgentsSection from '../components/instances/PeerAgentsSection';
import { SchemaGapBadge } from '../components/instances/SchemaGapBadge';
import { DEFAULT_PEER_PORT } from '../lib/ports.js';
import { DEFAULT_PEER_PORT, DEFAULT_TAILCAT_LOCAL_PORT } from '../lib/ports.js';
import PeerMediaProviderPanel from '../components/instances/PeerMediaProviderPanel';
import UnattendedRenderRouting from '../components/instances/UnattendedRenderRouting';
import BrainParityPanel from '../components/instances/BrainParityPanel';
Expand Down Expand Up @@ -207,7 +207,9 @@ function SelfCard({ self, onUpdate, syncStatus, tailnetInfo }) {
// Exported for focused tests (the port input's placeholder must advertise the
// same default the form actually submits — see Instances.test.jsx).
export function AddPeerForm({ onAdd, addressRef }) {
const [mode, setMode] = useState('classic'); // 'classic' | 'tailcat'
const [address, setAddress] = useState('');
const [tcAddress, setTcAddress] = useState('');
const [port, setPort] = useState(String(DEFAULT_PEER_PORT));
const [name, setName] = useState('');
const [showAuth, setShowAuth] = useState(false);
Expand All @@ -217,6 +219,24 @@ export function AddPeerForm({ onAdd, addressRef }) {

const handleSubmit = async (e) => {
e.preventDefault();
if (mode === 'tailcat') {
if (!tcAddress.trim()) return;
setAdding(true);
const data = { tcAddress: tcAddress.trim() };
if (name.trim()) data.name = name.trim();
if (password) data.auth = { username: username.trim(), password };
const result = await addTailcatPeer(data).catch(() => null);
setAdding(false);
if (!result) return;
setTcAddress('');
setName('');
setUsername('');
setPassword('');
setShowAuth(false);
onAdd();
toast.success(`Peer added via tailcat (local :${result.port || DEFAULT_TAILCAT_LOCAL_PORT})`);
return;
}
if (!address.trim()) return;
setAdding(true);
const data = { address: address.trim(), port: parseInt(port, 10) || DEFAULT_PEER_PORT };
Expand All @@ -237,11 +257,58 @@ export function AddPeerForm({ onAdd, addressRef }) {
toast.success('Peer added');
};

const canSubmit = mode === 'tailcat' ? !!tcAddress.trim() : !!address.trim();

return (
<form onSubmit={handleSubmit} className="bg-port-card border border-port-border rounded-xl p-5">
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Plus size={14} /> Add Peer
</h3>
<div className="flex flex-wrap gap-2 mb-3">
<button
type="button"
aria-pressed={mode === 'classic'}
onClick={() => setMode('classic')}
className={`text-xs px-2.5 py-1 rounded border transition-colors ${mode === 'classic' ? 'border-port-accent text-white bg-port-accent/20' : 'border-port-border text-gray-500 hover:text-gray-300'}`}
>
Host / port
</button>
<button
type="button"
aria-pressed={mode === 'tailcat'}
onClick={() => setMode('tailcat')}
className={`text-xs px-2.5 py-1 rounded border transition-colors ${mode === 'tailcat' ? 'border-port-accent text-white bg-port-accent/20' : 'border-port-border text-gray-500 hover:text-gray-300'}`}
>
Tailcat address
</button>
</div>
{mode === 'tailcat' ? (
<div className="flex flex-wrap gap-2">
<input
ref={addressRef}
aria-label="Tailcat address"
value={tcAddress}
onChange={e => setTcAddress(e.target.value)}
placeholder="tcEXAMPLE…"
required
className="bg-port-bg border border-port-border rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent flex-1 min-w-[200px] font-mono"
/>
<input
aria-label="Peer name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Name (optional)"
className="bg-port-bg border border-port-border rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent flex-1 min-w-[120px]"
/>
<button
type="submit"
disabled={adding || !canSubmit}
className="bg-port-accent hover:bg-port-accent/80 disabled:opacity-50 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
>
{adding ? 'Connecting...' : 'Add via tailcat'}
</button>
</div>
) : (
<div className="flex flex-wrap gap-2">
<input
ref={addressRef}
Expand Down Expand Up @@ -272,12 +339,20 @@ export function AddPeerForm({ onAdd, addressRef }) {
/>
<button
type="submit"
disabled={adding || !address.trim()}
disabled={adding || !canSubmit}
className="bg-port-accent hover:bg-port-accent/80 disabled:opacity-50 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
>
{adding ? 'Adding...' : 'Add'}
</button>
</div>
)}
{mode === 'tailcat' && (
<p className="text-[11px] text-gray-500 mt-2 leading-snug">
Forwards <span className="font-mono text-gray-400">127.0.0.1:{DEFAULT_TAILCAT_LOCAL_PORT}</span>
{' '}→ remote <span className="font-mono text-gray-400">:5555</span> via tailcat
(next free port if {DEFAULT_TAILCAT_LOCAL_PORT} is busy). No Tailscale account required.
</p>
)}
<div className="mt-2">
<button
type="button"
Expand Down Expand Up @@ -1195,6 +1270,11 @@ function PeerCard({ peer, onRefresh, syncStatus, tailnetInfo, parityReport }) {
<div className="mb-3">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-xs text-gray-500 font-mono">{peer.address}:{peer.port}</p>
{peer.transport === 'tailcat' && (
<Pill tone="accent" size="xs" bordered={false} title="Reachable via local tailcat forward (no Tailscale account)">
tailcat
</Pill>
)}
<DirectionBadge directions={peer.directions} />
{isInboundOnly && (
<button
Expand Down
40 changes: 38 additions & 2 deletions client/src/pages/Instances.test.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AddPeerForm } from './Instances.jsx';
import { DEFAULT_PEER_PORT } from '../lib/ports.js';
import { addPeer } from '../services/api';
import { DEFAULT_PEER_PORT, DEFAULT_TAILCAT_LOCAL_PORT } from '../lib/ports.js';
import { addPeer, addTailcatPeer } from '../services/api';

vi.mock('../services/api', () => ({
getInstances: vi.fn(),
updateSelfInstance: vi.fn(),
addPeer: vi.fn(),
addTailcatPeer: vi.fn(),
updatePeer: vi.fn(),
removePeer: vi.fn(),
connectPeer: vi.fn(),
Expand Down Expand Up @@ -50,3 +51,38 @@ describe('AddPeerForm port default', () => {
}));
});
});

describe('AddPeerForm tailcat path', () => {
beforeEach(() => {
vi.clearAllMocks();
addPeer.mockResolvedValue({ id: 'peer-1' });
addTailcatPeer.mockResolvedValue({ id: 'peer-tc', port: DEFAULT_TAILCAT_LOCAL_PORT, transport: 'tailcat' });
});

it('submits a pasted tc address through addTailcatPeer (not classic addPeer)', async () => {
render(<AddPeerForm onAdd={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Tailcat address' }));
const tc = 'tcEXAMPLE' + 'B'.repeat(40);
fireEvent.change(screen.getByLabelText('Tailcat address'), { target: { value: tc } });
fireEvent.click(screen.getByRole('button', { name: 'Add via tailcat' }));
await waitFor(() => expect(addTailcatPeer).toHaveBeenCalledWith({ tcAddress: tc }));
expect(addPeer).not.toHaveBeenCalled();
});

it('keeps classic host/port add working', async () => {
render(<AddPeerForm onAdd={() => {}} />);
fireEvent.change(screen.getByLabelText('Peer address'), { target: { value: '192.0.2.10' } });
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
await waitFor(() => expect(addPeer).toHaveBeenCalledWith({
address: '192.0.2.10',
port: DEFAULT_PEER_PORT,
}));
expect(addTailcatPeer).not.toHaveBeenCalled();
});

it('documents the 15555 local forward standard in the tailcat hint', () => {
render(<AddPeerForm onAdd={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Tailcat address' }));
expect(screen.getAllByText(new RegExp(String(DEFAULT_TAILCAT_LOCAL_PORT))).length).toBeGreaterThan(0);
});
});
1 change: 1 addition & 0 deletions client/src/services/apiSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ export const getSelfInstance = (options) => request('/instances/self', options);
export const getAssignableInstances = (options) => request('/instances/assignable', options);
export const updateSelfInstance = (data) => request('/instances/self', { method: 'PUT', body: JSON.stringify(data) });
export const addPeer = (data) => request('/instances/peers', { method: 'POST', body: JSON.stringify(data) });
export const addTailcatPeer = (data) => request('/instances/peers/tailcat', { method: 'POST', body: JSON.stringify(data) });
export const updatePeer = (id, data) => request(`/instances/peers/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const removePeer = (id) => request(`/instances/peers/${id}`, { method: 'DELETE' });
export const connectPeer = (id) => request(`/instances/peers/${id}/connect`, { method: 'POST' });
Expand Down
1 change: 1 addition & 0 deletions docs/PORTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Common port labels:
| 18022 | PortOS model host | - | Opt-in bearer-authenticated inference queue for dedicated hosts; one active generation. See [fleet host](./features/fleet-llm-host.md). |
| 18020 | vLLM (Docker) | - | Loopback vLLM Qwen3.8-27B / DFlash 2 container on an RTX 3090 host. Started explicitly from host setup or by the operator. Dedicated hosting opts into Docker restart persistence. See [features/qwen38-rtx3090.md](./features/qwen38-rtx3090.md). |
| 18021 | SGLang (Docker) | - | Loopback SGLang Qwen3.8-27B container on a Hopper/Blackwell host. Operator-started (`docker compose up -d`) — PortOS never brings it up on boot. See [features/sglang-qwen38.md](./features/sglang-qwen38.md). |
| 15555 | tailcat forward (loopback) | - | Preferred local listener for federated peers over [tailcat](https://github.com/tailscale/tailcat) (`PORTS.TAILCAT_FORWARD` / `DEFAULT_TAILCAT_LOCAL_PORT`). Maps `127.0.0.1:15555` → remote PortOS `:5555`. If busy, PortOS picks the next free port. See [features/tailcat-peers.md](./features/tailcat-peers.md). |

## How `:5555`, `:5553`, and `:5554` Relate

Expand Down
1 change: 1 addition & 0 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ PostgreSQL is a **required** install/runtime dependency (see [Backup & Restore](
- Spotify brain playlist shelf — `data/spotify/playlists.json`. `file-primary`, intentionally machine-local and never federated: it is a bounded cache of Spotify playlist and track metadata used as local reference material, while listening evidence remains in the federated Brain activity record. No sync cursor or tombstone. Adapter: `server/services/spotifyPlaylists.js`.
- IdeaLoom lists — `data/brain/idealoom-lists/{uuid}/index.json` with a schema-stamped collection index holding the disabled-by-default local integration settings. Lists retain their ordered idea strings, prompt/title/category/status/help, timestamps, and importer-owned local sync metadata. Explicit exchange reads/writes only the configured vault's `Idea Loom/` folder through the specialized `server/services/idealoomObsidian.js` parser/renderer; new notes use a date/title filename and imported note paths remain stable. **Intentionally machine-local — never federated, reconciled, or memory-bridged**: a vault id, note path, and content hash are meaningful only on the install that configured them. Native Brain `ideas` remain a separate federated collection. Exchange is base-hash reconciled: a note and a list that both changed since the stored hash report `conflicted` and neither is written, and a note deleted in the vault reports `missing` rather than being recreated (an iCloud note that is merely un-downloaded is `unavailable`, a separate outcome). Opt-in automatic export (`autoSync`, off by default, debounced by `server/services/idealoomAutoSync.js`) can only update an existing note — it never deletes, recreates, or resolves a conflict. Backed up in full with the rest of `data/brain/`; the vault notes themselves are the user's Obsidian data and are outside PortOS's snapshot. Adapters: `server/services/idealoomLists.js` (records), `server/services/idealoomObsidian.js` (exchange).
- Local-model assessments — `data/local-llm/assessments.json` (#4539). Measured evidence for one installed local model per (backend, model): the fit verdict (`fits`/`does-not-fit`/`incompatible`/`unknown`), per-context throughput/TTFT samples, resident footprint, and the coarse hardware environment the measurement was taken in. `file-primary` — a flat, capped, single-JSON projection with no cross-record queries and no relationships; the newest measurement replaces the old one per model rather than accumulating history. **Intentionally machine-local — never federated**: an assessment is a claim about THIS box, so a peer inheriting a 128 GB machine's its verdict for its 8 GB laptop would be actively wrong. No sync cursor, no tombstone, no `PORTOS_SCHEMA_VERSIONS` entry. Backed up (a run costs the user minutes of local compute), and the environment record deliberately carries no hostname/username/path. Adapter: `server/services/localModelAssessmentStore.js` (durable store + environment capture; no path to a provider, so read-only consumers like the catalog fit badge can import it); the run lives in `server/services/localModelAssessments.js` and the scoring in `server/lib/localModelAssessment.js`. Each record's environment is re-compared against the live machine on read, so a reading taken before a RAM upgrade or backend update is flagged stale rather than silently trusted.
- Tailcat peer forwards — `data/tailcat-forwards.json`. `file-primary`, **intentionally machine-local — never federated**: each row stores the bearer `tc…` address needed to restart `tailcat forward` after a PortOS reboot, plus the local/remote port mapping. The capability must not cross the wire or appear in logs in full (see [features/tailcat-peers.md](./features/tailcat-peers.md)). No sync cursor or tombstone. Adapter: `server/services/tailcatPeer.js`.
- LoRA training datasets — `data/lora-datasets/{id}/index.json` + `images/*.png` (collectionStore). The record is inseparable from the image bytes it organizes, has no cross-record queries beyond a small characterId scan, and is **machine-local** like `data/loras/` itself (training artifacts tied to this machine's GPU output — never federates, no sync cursor/tombstone). Backed up in full: uploads and hand-edited captions are not re-creatable. Training RUN records are `db-primary` (`lora_training_runs`); run artifacts (checkpoints/samples) live under `data/training-runs/{runId}/` with checkpoints/cache excluded from backup.

---
Expand Down
96 changes: 96 additions & 0 deletions docs/features/tailcat-peers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Federated peers via tailcat (no Tailscale account)

PortOS can federate with another install over
[tailcat](https://github.com/tailscale/tailcat) — Tailscale's userspace
WireGuard + DERP data plane **without** a Tailscale account, daemon, or
tailnet. Use this when an untrusted sandbox (or any machine that cannot join
your tailnet) needs to reach a home PortOS on `:5555`.

## Port standard

| Side | Port | Role |
|------|------|------|
| Home / remote PortOS | **5555** | Existing PortOS API (`PORTS.API`) |
| Operator / client PortOS | **15555** (preferred) | Local `tailcat forward` listener (`PORTS.TAILCAT_FORWARD` / `DEFAULT_TAILCAT_LOCAL_PORT`) |

Mapping: `tailcat forward <tcADDR> 15555:5555` binds `127.0.0.1:15555` to the
remote's `:5555`. If `15555` is already taken, PortOS walks upward to the next
free loopback port and registers the peer at that local port instead.

No `tailcat serve all`, no exit-node mode, and no Tailscale daemon are used.

## Operator flow (Instances UI)

1. Open **Instances → Add Peer → Tailcat address**.
2. Paste the peer's `tc…` address (received out of band).
3. PortOS ensures `tailcat` is installed (PATH, else `go install
github.com/tailscale/tailcat/cmd/tailcat@latest`), starts the forward, and
calls the normal peer registration against `127.0.0.1:<localPort>` over HTTP.
4. Classic **Host / port** add remains unchanged (still rejects loopback).

Forwards are persisted in machine-local `data/tailcat-forwards.json` so PortOS
can restart them on boot. The full `tc…` string is a bearer capability — it is
**never** logged in full, never placed on the peer record returned to the UI or
to other peers, and must never appear in commits, PR bodies, docs, or tests.
Use placeholders such as `<tcADDR>` or `tcEXAMPLE…` only.

## Privacy

- Do not paste real `tc…` addresses into tickets, chat logs synced to peers, or
screenshots that leave the machine.
- Server logs print a redacted form (`tcAB…wxyz`) only.
- Removing a peer stops its managed forward.

## Grok Bot / agent sandbox setup (copy/paste)

Use this when an **untrusted agent sandbox** should run PortOS and hand the
operator a tailcat address so the home install can federate in.

### On the sandbox (serve)

```bash
# Install tailcat (pick one)
go install github.com/tailscale/tailcat/cmd/tailcat@latest
# or: brew install tailcat
# or: download a release from https://github.com/tailscale/tailcat/releases

# PortOS already listening on :5555 in the sandbox, then:
tailcat serve --key=new 5555
# stderr prints: 🐈 Server listening with new address: <tcADDR>
```

Share `<tcADDR>` with the operator **out of band** (private chat, 1Password,
operator-only channel). Do not commit it, put it in the repo, or log it to a
synced surface.

Optional named key (stable address across restarts — still a secret):

```bash
tailcat genkey --key=portos-sandbox
tailcat serve --key=portos-sandbox 5555
```

### On the operator PortOS (forward + peer)

In **Instances → Add Peer → Tailcat address**, paste `<tcADDR>`.

Or manually:

```bash
tailcat forward <tcADDR> 15555:5555
# then Add Peer → Host/port is not used for loopback; prefer the UI Tailcat path
# which registers 127.0.0.1:15555 for you.
```

### Checklist for agents

- [ ] PortOS up on sandbox `:5555`
- [ ] `tailcat serve --key=… 5555` (not `serve all`, not exit-node)
- [ ] Hand operator `<tcADDR>` out of band only
- [ ] Operator uses UI Tailcat add (local **15555 → 5555**)
- [ ] Never write real `tc…` values into git, PR text, or federated logs

## Related

- [PORTS.md](../PORTS.md) — `TAILCAT_FORWARD` / `15555`
- [tailscale/tailcat](https://github.com/tailscale/tailcat) — CLI reference
Loading