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
14 changes: 14 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,20 @@ also returns `blocked` (why nothing could be read, or null) and the normalised `
- **A failed attach after a successful launch** leaves the tmux session running on the host; the error names it, and it appears in the sidebar at the next refresh.
- Not verified here: a real host (the tests use a fake runner, plus a real `sh` with stubbed `tmux`/`claude`), tmux older than the `-P -F` form, and a `claude` that exits at once (the pane then closes and attach fails with the probe error).

## Remote hosts — enrolment (issue #222)

`remote-enrol.js` builds the checklist and guards the request; the command and its parser are in `remote-transport.js` (`ENROL_COMMAND`, `parseEnrol`, `checkHost`); the IPC is `remote-host-enrol-check`, the UI is `public/remote-enrol-panel.js` driven from the host rows of `settings-panel.js`.

- **It reports state, it acquires nothing.** The sensitive-path denylist refuses `.claude/.credentials.json` on purpose (#208), so no check opens, copies, hashes or tests that file, and none reads `ANTHROPIC_*`, a keychain or a token. Pinned by a negative match on `ENROL_COMMAND` in `test/remote-transport-enrol.test.js`.
- **The logged-in signal is the CLI's own exit status.** `claude auth status` (verified locally, CLI 2.1.288, read-only) exits 0 when logged in and 1 when not, and prints JSON or text that includes the email and organisation. The command runs it with stdout and stderr thrown away (`>/dev/null 2>&1 </dev/null`), so the account details never cross ssh; only `auth=1|0|unknown` comes back. Any other exit status, a host whose `claude auth --help` has no `status` line (an older CLI, where `auth` would be read as a prompt), a missing `claude` or a missing `~/.claude` give `auth=unknown`, never "logged out". With no `~/.claude` the command is not run at all, because on a fresh account the CLI creates its config files on first use and the check must not change the host.
- **`ENROL_COMMAND` extends `PROBE_COMMAND` by concatenation**; the probe's own string and parser stay pinned and unchanged, as does `LIST_COMMAND`. Fixed string, no interpolation: the alias is the ssh operand, validated by `isValidAlias` and required to be in the saved `remoteHosts` before any ssh runs.
- **Strict parse.** `parseEnrol` takes the lines `tmux`, `inotifywait`, `claude`, `claude_version` (only when `claude=1`), `claude_dir`, `auth` in that order and nothing else, or returns null. The version is the one free-text field: it is kept only when it matches `CLAUDE_VERSION_RE`, else shown as unreadable. Output cap 2 KiB, timeout 30 s (`enrolTimeoutMs`), the same `run()` and ssh options.
- **Outcomes of `checkHost`**: ssh exit 255, a spawn failure or a timeout is `reachable: false`; any other failure is `reachable: true` with no facts (a Windows host lands here), and every other item is then `unknown`, not `missing`. Detail text from stderr is one line, control characters stripped, 200 characters.
- **The hand-off commands** are constants in `remote-enrol.js`: the install one-liner and `sudo apt install tmux` are assumptions about the host, offered as text to copy and never run by Switchboard. Each carries `where` (`host` or `workstation`) so the UI says where to run it. tmux is `optional`: its absence is observe-only, not an error.
- **The UI builds its DOM with `textContent` only**; the copy button goes through `window.api.writeClipboard` (main process, as the Wayland fix). The check applies to hosts in the saved settings; an unsaved row answers "save the settings first". One check per alias at a time (`running` set in `remote-enrol.js`).
- **A non-POSIX login shell** (fish, csh) fails the command like a Windows host does, so the "no facts" detail names both causes. The command is not wrapped in `sh -c`: the `PROBE_COMMAND` prefix pin must hold.
- Not verified: whether `claude auth status` refreshes or rewrites an expired token, or makes network calls, on the host (Switchboard only sees the exit status, but the command is not known to be free of side effects on the host); a real host (tests use a fake spawn and a real `sh` with a stubbed `claude`), `claude auth login` on a machine with no browser, and whether `claude auth status` in a non-interactive ssh sees a login provided only by an environment variable set in an interactive profile (it would read "not logged in").

## Remote hosts — sending a prompt (issue #219)

`remote-send.js` writes one prompt to a live, unattached remote session through
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
- A session's **Touched** tab, next to Changes in the terminal header, lists the files its file tools (Edit, Write, MultiEdit, NotebookEdit) touched, its subagents' included, with what is on disk now (present, gone, unreadable) and the tools and agents behind each. It works outside any git repository. It is not the complete set of files the session changed: files changed through Bash commands or scripts are not listed, and the tab says so. Local sessions only. (#309)
- With Debug mode on, the activity trace now records how hard each terminal is being drawn: once a second per session, how many writes reached it, how large they were and how often its glyph atlas was rebuilt, to tell a legitimately busy terminal from a runaway one. (#175)
- On a remote host with `tmux`, the project's `+` now starts a new Claude session there: pick or type a directory on the host, choose a permission mode, and Switchboard starts it in a tmux session and attaches to it. The directory must already exist on the host, `claude` must be on the PATH of an ssh command, and signing in is done on the host. (#218, #222)
- Each remote host in Settings has a **Check host** button: it reports whether the host is reachable, has `claude` (with its version), `tmux` and a `~/.claude`, and whether the account is logged in. For anything missing it shows the command to run on the host, with a copy button. It does not install, log in or write anything itself, and never copies or reads credentials. A login it cannot tell is shown as unknown, not as logged out. Linux hosts only. (#222)
### Changed
- A single trigger is no longer typed into a dialog such as a permission prompt or a question: with `wait: "none"` (write now, the default) it holds while the CLI shows a dialog, and with `wait: "idle"` until the CLI is at its prompt, up to its `timeout_ms`; then it fails `not sent` with a `reason` that says a dialog is open instead of being written into it. `wait: "none"` still writes at once while the CLI is busy. Without a readable CLI descriptor it is written as before. Input you type yourself in the terminal is never held back. (#379)
- Switchboard now checks once per host, at the first successful refresh and then every six hours (every 30 minutes while one is missing), whether `tmux` and `inotifywait` are installed. A host with `tmux` and no session running no longer shows attach as missing; a host without `tmux` no longer offers to attach to a session and opens its transcript, saying why in the tooltip; and the host's tooltip says when live updates are off because `inotifywait` is missing. On a remote host, the new-session button's tooltip now gives the reason, and Send a prompt… is disabled, with the reason, while no live session on the host reports a messaging socket. Stop is never disabled. (#218)
Expand Down
35 changes: 35 additions & 0 deletions docs/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,41 @@ The search through the `PATH` and the system locations also runs once. An
`ssh` or `scp` it found that is later removed is searched for again at its
next use; one installed after nothing was found is seen after a restart.

### Check host

Each host row in Settings has a **Check host** button. It runs one read-only
`ssh` to the host and shows a checklist, one line per item with its status (ok,
missing or unknown), and for a missing or unknown item the command to run, with
a **Copy** button and where to run it:

| Item | What is read |
|---|---|
| ssh reachable | whether the connection (same `ssh`, `BatchMode=yes`) succeeded |
| claude CLI | whether `claude` is on the `PATH` of an ssh command, and its `--version` |
| tmux | whether `tmux` is installed; without it the host is observed but cannot launch or attach (optional) |
| `~/.claude` | whether the directory exists |
| account logged in | the exit status of `claude auth status` (0 logged in, 1 not) |

The check does not install, log in or write anything itself: the
commands it hands you are for you to run on the host (`ssh -t <alias>`).
Switchboard never copies or reads credentials. The login check does not open the
credentials file or test that it exists: it asks the CLI, throws away what the
CLI prints (which includes your email), and keeps only the exit status. When the
state cannot be told (an older CLI without `claude auth status`, no `~/.claude`
yet, `claude` missing, an unusual exit status) the line says *unknown — run
`claude` on the host once to log in*, never "not logged in".

Not verified: whether `claude auth status` itself refreshes or rewrites an
expired token, or makes network calls, when the CLI runs it on the host. The
check neither asks for nor sees any of that; it only receives the exit status.

Limits: the host must be saved first (the button checks the hosts in the saved
settings); a Linux host whose login shell is POSIX is required (the command is
not wrapped in `sh -c`), so a Windows host, or a host whose login shell is fish
or csh, is reported as not checked; only `tmux` is looked for. If the CLI is logged in only
through an environment variable set by an interactive profile, the check, which
runs in a non-interactive shell, reads "not logged in".

## Requirements on the host

- A Linux host (`/proc` is read for liveness, attach and stop), a POSIX shell
Expand Down
16 changes: 16 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ const rendererCrossFileGlobals = {
openSettingsViewer: 'readonly',
wireActivityTraceToggle: 'readonly',
wireActivityReportingToggle: 'readonly',
wireRemoteEnrolControls: 'readonly',
renderActivityReportingStatus: 'readonly',
renderActivityTraceFiles: 'readonly',
openActivityTraceFile: 'readonly',
Expand Down Expand Up @@ -442,6 +443,21 @@ module.exports = [
},
},

// Producer of the host checklist global that settings-panel.js consumes.
{
files: ['public/remote-enrol-panel.js'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'script',
globals: { ...globals.browser, wireRemoteEnrolControls: 'off' },
},
rules: {
'no-undef': 'error',
'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }],
'no-redeclare': 'warn',
},
},

// Dual-mode Settings section (public/activity-reporting-panel.js — see
// .ai/contexts/activitywatch.md): classic <script> in the renderer, and
// require()-d in node:test for its status text. It declares the two globals
Expand Down
9 changes: 8 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry, initialScheduleProjects } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand Down Expand Up @@ -482,13 +482,14 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 485 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 485 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 486 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache;
const { resolveJsonlPath, readSubagentMeta } = require('./read-session-file');

// --- Remote SSH hosts (observation only) — see .ai/contexts/session-cache.md ---
const { isRemoteFolder, parseFolderKey, joinFolderKey, enabledHosts } = require('./remote-hosts');
const { isRemoteFolder, parseFolderKey, joinFolderKey, enabledHosts, normalizeHosts } = require('./remote-hosts');
const { handleEnrolRequest } = require('./remote-enrol');
const REMOTE_READ_ONLY = 'remote sessions are read-only — this build observes them, it does not attach to them';
const { createSshTransport } = require('./remote-transport');
require('./remote-ssh-binary').setResolverLog(log);
Expand Down Expand Up @@ -1587,6 +1588,12 @@
}
});

// see .ai/contexts/session-cache.md ("Remote hosts — enrolment")
ipcMain.handle('remote-host-enrol-check', (_event, alias) => handleEnrolRequest({ alias }, {
isDeclared: (a) => normalizeHosts((getSetting('global') || {}).remoteHosts).some(h => h.alias === a),
transport: remoteTransport,
}));

ipcMain.handle('remote-host-refresh', async (_event, alias) => {
try {
const result = await remoteIndexer.refreshHostNow(alias, { force: true });
Expand Down Expand Up @@ -2471,7 +2478,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2481 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ contextBridge.exposeInMainWorld('api', {
remoteHostsApply: () => ipcRenderer.invoke('remote-hosts-apply'),
remoteHostsRefresh: () => ipcRenderer.invoke('remote-hosts-refresh'),
remoteHostRefresh: (alias) => ipcRenderer.invoke('remote-host-refresh', alias),
remoteHostEnrolCheck: (alias) => ipcRenderer.invoke('remote-host-enrol-check', alias),
getScheduleCreatorCommand: () => ipcRenderer.invoke('get-schedule-creator-command'),
createScheduleSession: (projectPath) => ipcRenderer.invoke('create-schedule-session', projectPath),
runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath),
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
<script src="process-exit.js"></script>
<script src="file-panel.js"></script>
<script src="touched-files-view.js"></script>
<script src="remote-enrol-panel.js"></script>
<script src="settings-panel.js"></script>
<script src="activity-trace-panel.js"></script>
<script src="activity-reporting-panel.js"></script>
Expand Down
74 changes: 74 additions & 0 deletions public/remote-enrol-panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// remote-enrol-panel.js — the host checklist in Settings — see .ai/contexts/session-cache.md ("Remote hosts — enrolment")

'use strict';

const ENROL_WHERE_TEXT = { host: 'Run on the host:', workstation: 'Run on this machine:' };

function enrolEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}

function renderEnrolChecklist(containerEl, result) {
if (!containerEl) return;
containerEl.textContent = '';
if (!result || result.ok !== true || !Array.isArray(result.items)) {
containerEl.appendChild(enrolEl('div', 'enrol-error settings-description', (result && result.error) || 'the check returned no answer'));
return;
}
for (const it of result.items) {
const row = enrolEl('div', 'enrol-item');
row.dataset.status = String(it.status);
const head = enrolEl('div', 'enrol-head');
head.appendChild(enrolEl('span', 'enrol-status', String(it.status)));
head.appendChild(enrolEl('span', 'enrol-label', String(it.label)));
row.appendChild(head);
row.appendChild(enrolEl('div', 'enrol-detail settings-description', String(it.detail)));
if (typeof it.command === 'string' && it.command) {
const cmd = enrolEl('div', 'enrol-command');
cmd.appendChild(enrolEl('span', 'enrol-where', ENROL_WHERE_TEXT[it.where] || ENROL_WHERE_TEXT.host));
cmd.appendChild(enrolEl('code', '', it.command));
const copy = enrolEl('button', 'enrol-copy settings-check-updates-btn', 'Copy');
copy.type = 'button';
copy.addEventListener('click', async () => {
try {
await window.api.writeClipboard(it.command);
copy.textContent = 'Copied';
} catch {
copy.textContent = 'Failed';
}
});
cmd.appendChild(copy);
row.appendChild(cmd);
}
containerEl.appendChild(row);
}
}

function wireRemoteEnrolControls(buttonEl, containerEl, getAlias) {
if (!buttonEl) return;
buttonEl.addEventListener('click', async () => {
const alias = String(getAlias() || '').trim();
if (!alias) {
renderEnrolChecklist(containerEl, { ok: false, error: 'type an ssh alias first' });
return;
}
buttonEl.disabled = true;
containerEl.textContent = '';
containerEl.appendChild(enrolEl('div', 'enrol-pending settings-description', `Checking ${alias}…`));
try {
renderEnrolChecklist(containerEl, await window.api.remoteHostEnrolCheck(alias));
} catch (err) {
renderEnrolChecklist(containerEl, { ok: false, error: `check failed: ${err.message}` });
} finally {
buttonEl.disabled = false;
}
});
}

if (typeof window !== 'undefined') {
window.renderEnrolChecklist = renderEnrolChecklist;
window.wireRemoteEnrolControls = wireRemoteEnrolControls;
}
7 changes: 6 additions & 1 deletion public/settings-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,18 @@
<label class="settings-toggle"><input type="checkbox" class="rh-enabled" ${h.enabled ? 'checked' : ''}><span class="settings-toggle-slider"></span></label>
<input type="text" class="settings-input rh-alias" placeholder="ssh alias" value="${escapeHtml(h.alias)}">
<input type="text" class="settings-input rh-label" placeholder="label (optional)" value="${escapeHtml(h.label)}">
<button class="settings-check-updates-btn rh-check" title="Check this host: ssh, claude, tmux, ~/.claude and the login. Read-only.">Check host</button>
<button class="settings-remove-btn rh-remove" title="Remove host">Remove</button>
</div>`).join('');
</div>
<div class="remote-host-enrol" data-i="${i}"></div>`).join('');
listEl.querySelectorAll('.remote-host-row').forEach(row => {
const i = Number(row.dataset.i);
row.querySelector('.rh-alias').addEventListener('input', e => { remoteHosts[i].alias = e.target.value; });
row.querySelector('.rh-label').addEventListener('input', e => { remoteHosts[i].label = e.target.value; });
row.querySelector('.rh-enabled').addEventListener('change', e => { remoteHosts[i].enabled = e.target.checked; });
if (typeof wireRemoteEnrolControls === 'function') {
wireRemoteEnrolControls(row.querySelector('.rh-check'), listEl.querySelector(`.remote-host-enrol[data-i="${i}"]`), () => remoteHosts[i].alias);
}
row.querySelector('.rh-remove').addEventListener('click', () => {
remoteHosts.splice(i, 1);
renderRemoteHosts();
Expand Down
46 changes: 46 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -4482,6 +4482,52 @@ body { display: flex; flex-direction: column; }
min-width: 0;
}

.remote-host-enrol:empty {
display: none;
}

.remote-host-enrol {
margin: 0 0 10px 12px;
padding-left: 10px;
border-left: 2px solid var(--control-surface);
}

.enrol-item {
margin-bottom: 8px;
}

.enrol-head {
display: flex;
gap: 8px;
align-items: baseline;
}

.enrol-status {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}

.enrol-item[data-status="ok"] .enrol-status { color: #3ecf5a; }
.enrol-item[data-status="missing"] .enrol-status { color: #e5534b; }
.enrol-item[data-status="unknown"] .enrol-status { color: #d4a72c; }

.enrol-command {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 4px;
}

.enrol-command code {
user-select: all;
}

.enrol-error {
color: #e5534b;
}

/* Terminal sessions: green status dot */
.session-item.is-terminal .session-icon.running {
background: #3ecf5a;
Expand Down
Loading
Loading