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
163 changes: 132 additions & 31 deletions README.md

Large diffs are not rendered by default.

236 changes: 200 additions & 36 deletions app.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion core_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Core release identity, shared by source and packaged launchers."""

CORE_VERSION = '2.12.1'
CORE_VERSION = '2.13.0'
31 changes: 19 additions & 12 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,23 @@ The evaluation uses ad-hoc signing, without Developer ID or notarization; it is
not a Gatekeeper-qualified public release. No signing account or private key is
needed for a local build. Intel/Rosetta acceptance is not implied.

New build filenames pair the independent Desktop and bundled Core versions:
`StandTerm-Desktop-0.5.0-2.12.1-mac-arm64.dmg` and
`StandTerm-Desktop-0.5.0-2.12.1-win32-x64-Setup.exe`.
The [Desktop 0.5.0 evaluation release](https://github.com/askac/standterm/releases/tag/desktop-v0.5.0-2.12.1)
contains paired packages, checksums and validation evidence. Core 2.12.1 remains
a separate source release; older Desktop releases retain their original files.
New build filenames pair the independent Desktop and bundled Core versions.
The local 0.5.0 / Core 2.12.1 candidates use that paired label. The published
[Desktop 0.4.3 evaluation](https://github.com/askac/standterm/releases/tag/desktop-v0.4.3)
retains its original filenames and does not include the later Core features.
Core source releases and Desktop installer publication are separate.

The local Windows **0.5.1 / Core 2.13.0-dev** candidate includes ordered SSH jump
routes, per-site login cards, shared Direct/node key controls and opt-in
profile/route saving on Connect. It retains its original development identity.
The current source declares Core **2.13.0**; matching installers require a fresh
build and validation. Neither local candidate is a published Desktop release.

Staging writes `release-identity.json` from the staged package/lock versions and
the manifest-hashed `core_version.py`. The builder revalidates this identity and
fails on missing or inconsistent inputs. Build from the printed stage, not the
source Desktop directory. Package and lock versions remain Desktop-only SemVer
(`0.5.0`), so the combined label cannot change installer upgrade ordering. The
(`0.5.1`), so the combined label cannot change installer upgrade ordering. The
Core qualifier is preserved, and About/Diagnostics continue to show separate
versions. A label alone is not a source or Core bundle identity.

Expand Down Expand Up @@ -73,7 +78,7 @@ Build on an Apple Silicon Mac with Node 22.12+ and the checkout's macOS venv:
```sh
cd desktop
npm ci
npm run stage:mac -- --core-ref v2.12.1
npm run stage:mac -- --core-ref v2.13.0
# Change to the absolute stage directory printed above, then:
npm ci
npm run make:mac
Expand All @@ -90,6 +95,8 @@ The Desktop package workflow builds on native `macos-15` arm64 and
compares the complete staged Core payload to the formal tag, inspects the app
and mounted DMG, and records native smoke results with the artifact. Automated
checks do not replace Finder, Gatekeeper, IME or installer-upgrade acceptance.
The workflow pins its own `CORE_REF` and `CORE_COMMIT`; update that pair before
using it to package a newer Core release.

For isolated verification, run `npm test`, `npm run smoke:capture`, the Python
bootstrap/runtime/backend tests, and `test/browser-storage-smoke.cjs` with the
Expand Down Expand Up @@ -298,7 +305,7 @@ Permanent disposal of recovery files is a separate, explicit user action.
Build tools need Windows Node.js 22.12+ and Git. From the repository:

```powershell
node desktop/stage-windows.cjs --core-ref v2.12.1
node desktop/stage-windows.cjs --core-ref v2.13.0
# Change to the exact desktop/dist/windows-build-* path printed above.
cd <printed-build-directory>
npm ci
Expand All @@ -308,13 +315,13 @@ npm run make:win
The staging script creates a new directory each time. `--core-ref` selects Core
files and bytes from that Git revision; the Desktop shell comes from the current
checkout. Verify a release stage with `node desktop/test/core-release-inspect.cjs
<stage> v2.12.1`, then inspect the actual package with `test/package-inspect.cjs`.
<stage> v2.13.0`, then inspect the actual package with `test/package-inspect.cjs`.
Without `--core-ref`, staging uses tracked working-tree Core files for development
snapshots; these are not a claim that changes are already a release. Build staging never copies
the development venv or `node_modules`. The new directory gets Windows build
dependencies; the source checkout's Linux/WSLg `node_modules` is untouched.

The `StandTerm-Desktop-0.5.0-2.12.1-win32-x64-Setup.exe` is under `out/`; the unpacked
The `StandTerm-Desktop-0.5.1-2.13.0-win32-x64-Setup.exe` is under `out/`; the unpacked
application is under `out/win-unpacked/`. Packaging uses
[electron-builder's assisted NSIS target](https://www.electron.build/nsis.html),
with pinned build dependencies and scoped custom installer hooks. Squirrel
Expand Down Expand Up @@ -625,7 +632,7 @@ managed Core bundle SHA-256 identity when available. The same Core details are
in Diagnostics. Core reports its version from `core_version.py`, independently
of the Electron package version. Source checkouts have no managed build identity;
older backends that omit version metadata show Unknown, never an inferred Git
tag. The current source pairing is Desktop 0.5.0 / Core 2.12.1. The Core source
tag. The current source pairing is Desktop 0.5.1 / Core 2.13.0. The Core source
release does not publish or qualify Desktop installers. The Agent menu and
expanded Core payload postdate the
published 0.4.1 installer and the earlier macOS 0.4.2 candidate; they require a
Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "standterm-desktop-evaluation",
"version": "0.5.0",
"version": "0.5.1",
"private": true,
"productName": "StandTerm Desktop",
"author": "ASKA C.",
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Flask
Flask-SocketIO
simple-websocket
paramiko
paramiko>=3.2
eventlet
cryptography
webauthn>=3,<4
Expand Down
6 changes: 6 additions & 0 deletions scripts/run_smoke_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@
'tests/agent_rsfile_smoke.py',
'tests/static_site_smoke.py',
'tests/terminal_read_smoke.py',
'tests/ssh_start_smoke.py',
'tests/ssh_login_smoke.py',
'tests/ssh_node_credentials_smoke.py',
'tests/ime_anchor_browser_smoke.py',
]

HEADLESS_SMOKE_TESTS = [
'tests/access_window_smoke.py',
'tests/terminal_read_smoke.py',
'tests/ssh_start_smoke.py',
'tests/ssh_login_smoke.py',
'tests/ssh_node_credentials_smoke.py',
'tests/server_startup_smoke.py',
'tests/external_agent_boundary_smoke.py',
'tests/agent_repl_smoke.py',
Expand Down
79 changes: 79 additions & 0 deletions static/js/standterm-ssh-host-identity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
(function() {
'use strict';

window.StandTermSshHostIdentity = function({ parent, read, request, editorId, nodeId }) {
const status = document.createElement('p');
status.className = 'ssh-host-identity-status';
status.setAttribute('role', 'status');
const actions = document.createElement('div');
actions.className = 'ssh-route-buttons';
const confirmation = document.createElement('div');
confirmation.className = 'ssh-host-identity-confirm';
parent.append(status, actions, confirmation);
let revision = 0;
let pendingAction = null;
const button = (label, callback, container = actions) => {
const value = document.createElement('button');
value.type = 'button';
value.textContent = label;
value.onclick = callback;
container.append(value);
return value;
};
const refresh = button('Check saved fingerprint', () => perform('inspect'));
const forget = button('Forget saved fingerprint…', () => perform('prepare_forget'));
function invalidate() {
revision += 1;
pendingAction = null;
confirmation.replaceChildren();
status.textContent = 'Check the saved fingerprint for this host identity.';
refresh.disabled = false;
forget.hidden = true;
}
function show(result) {
const fingerprints = Array.isArray(result.fingerprints) ? result.fingerprints : [];
status.textContent = `${result.identity}\n${fingerprints.length ? fingerprints.join('\n') : 'No saved fingerprint.'}\nThe server will be verified when connecting.`;
forget.hidden = !fingerprints.length;
}
async function perform(operation, action = null) {
let values;
try { values = read(); } catch (err) { status.textContent = err.message; return; }
const snapshot = JSON.stringify(values);
const token = ++revision;
const payload = { ...values, editor_id: editorId, node_id: nodeId,
request_id: StandTermSshRoutes.id(), operation, ...(action ? { action_id: action } : {}) };
refresh.disabled = true;
forget.disabled = true;
confirmation.replaceChildren();
status.textContent = 'Checking host identity…';
try {
const result = await request(payload);
if (!parent.isConnected || revision !== token || JSON.stringify(read()) !== snapshot) return;
if (!result || result.request_id !== payload.request_id || result.editor_id !== editorId || result.node_id !== nodeId) {
throw new Error('SSH host identity reply is stale. Check again.');
}
if (result.status === 'failed') throw new Error(result.message || 'SSH host identity is unavailable.');
if (result.status === 'confirm') {
pendingAction = result.action_id;
status.textContent = result.message;
const question = document.createElement('p');
question.textContent = result.question;
confirmation.append(question);
button('Forget now', () => { if (pendingAction === result.action_id) perform('confirm', pendingAction); }, confirmation);
const cancel = button('Keep fingerprint', () => { if (pendingAction === result.action_id) perform('cancel', pendingAction); }, confirmation);
cancel.focus();
} else {
pendingAction = null;
if (result.status === 'cancelled') { invalidate(); perform('inspect'); }
else show(result);
}
} catch (err) {
if (parent.isConnected && revision === token) status.textContent = err.message || 'SSH host identity is unavailable.';
} finally {
if (revision === token) { refresh.disabled = false; forget.disabled = false; }
}
}
invalidate();
return { invalidate, inspect: () => perform('inspect') };
};
})();
158 changes: 158 additions & 0 deletions static/js/standterm-ssh-login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
(function() {
'use strict';

window.StandTermSshLogin = function({ terminalId, attemptId, route, send, cancel, back, isActive }) {
const element = document.createElement('section');
element.className = 'ssh-login-flow';
const progress = document.createElement('div');
progress.className = 'ssh-login-progress';
progress.setAttribute('aria-label', 'SSH connection progress');
const source = document.createElement('span');
source.textContent = 'Core';
source.dataset.phase = 'complete';
progress.append(source);
element.append(progress);
const message = document.createElement('p');
message.className = 'ssh-login-message';
message.setAttribute('role', 'status');
const actions = document.createElement('div');
actions.className = 'ssh-login-actions';
const button = (label, callback, parent) => {
const value = document.createElement('button');
value.type = 'button';
value.textContent = label;
value.onclick = callback;
parent.append(value);
return value;
};
const labels = { waiting: 'Waiting', connect: 'Connecting', forward: 'Opening next hop',
verify_and_authenticate: 'Verifying host', local_keys: 'Trying local keys', host_key: 'Confirm host key',
password: 'Requires password', authenticate: 'Authenticating', authenticated: 'OK',
shell: 'Opening terminal', complete: 'OK', failed: 'Failed' };
const nodes = route.map((node, index) => {
const role = index === route.length - 1 ? 'Target' : `Node ${index + 1}`;
const marker = document.createElement('span');
marker.textContent = role;
marker.dataset.phase = 'waiting';
progress.append(document.createTextNode(' → '), marker);
const card = document.createElement('section');
card.className = 'ssh-login-card';
card.dataset.nodeId = node.node_id;
card.dataset.phase = 'waiting';
const heading = document.createElement('div');
heading.className = 'ssh-login-card-heading';
const title = document.createElement('strong');
title.textContent = role;
const endpoint = document.createElement('span');
endpoint.className = 'ssh-login-endpoint';
endpoint.textContent = `${node.username}@${node.host}:${node.port}${node.host_key_alias ? ` · ${node.host_key_alias}` : ''}`;
endpoint.title = endpoint.textContent;
const status = document.createElement('span');
status.className = 'ssh-login-card-status';
status.textContent = labels.waiting;
heading.append(title, endpoint, status);
const body = document.createElement('div');
body.className = 'ssh-login-card-body';
body.hidden = true;
card.append(heading, body);
element.append(card);
return { id: node.node_id, role, card, marker, status, body, phase: 'waiting', request: null };
});
element.append(message, actions);
const cancelButton = button('Cancel connection', cancel, actions);
const backButton = button('Back to connection settings', back, actions);
backButton.hidden = true;
let activeIndex = -1;
let finished = false;
const requests = new Set();

function clearInput(node) {
node.body.querySelectorAll('input').forEach(input => { input.value = ''; });
node.request = null;
node.body.replaceChildren();
node.body.hidden = true;
}

function setPhase(node, phase) {
node.phase = phase;
node.card.dataset.phase = phase;
node.marker.dataset.phase = phase;
node.status.textContent = labels[phase];
}

function focus() {
if (!isActive() || finished) return;
const field = nodes[activeIndex]?.body.querySelector('input, [data-default-focus]');
if (field && !field.disabled && field.isConnected) field.focus();
}

function handle(data) {
if (finished || data.attempt_id !== attemptId || data.terminal_id !== terminalId) return false;
const index = nodes.findIndex(node => node.id === data.node_id);
if (index < activeIndex || index < 0 || data.hop !== index + 1 || data.total !== nodes.length) return false;
const node = nodes[index];
if (!Object.hasOwn(labels, data.phase)) return false;
const prompt = data.message_type === 'ssh_login_prompt';
if (prompt && (!['password', 'host_key'].includes(data.kind) || data.phase !== data.kind
|| typeof data.request_id !== 'string' || requests.has(data.request_id))) return false;
if (['authenticated', 'complete'].includes(node.phase) && data.phase !== 'shell') return false;
activeIndex = index;
clearInput(node);
nodes.forEach((item, i) => { if (i !== index) clearInput(item); });
setPhase(node, index === nodes.length - 1 && data.phase === 'authenticated' ? 'shell' : data.phase);
if (!prompt) return true;
requests.add(data.request_id);
node.request = data.request_id;
node.body.hidden = false;
const explanation = document.createElement('p');
explanation.textContent = data.message || '';
node.body.append(explanation);
const reply = fields => {
if (finished || !isActive() || node.request !== data.request_id) return;
const payload = { terminal_id: terminalId, attempt_id: attemptId, node_id: node.id,
request_id: data.request_id, kind: data.kind, ...fields };
clearInput(node);
setPhase(node, data.kind === 'password' ? 'authenticate' : 'verify_and_authenticate');
send(payload);
};
if (data.kind === 'password') {
const form = document.createElement('form');
const label = document.createElement('label');
label.textContent = 'Password';
const input = document.createElement('input');
input.type = 'password';
input.autocomplete = 'off';
input.setAttribute('aria-label', `${node.role} password`);
label.append(input);
form.append(label);
const submit = button('Log in', () => {}, form);
submit.type = 'submit';
form.onsubmit = event => { event.preventDefault(); reply({ password: input.value }); };
node.body.append(form);
} else {
const question = document.createElement('p');
question.textContent = data.question || 'Remember this host key?';
node.body.append(question);
button('Trust and continue', () => reply({ accept: true }), node.body);
button('Cancel connection', cancel, node.body).dataset.defaultFocus = 'true';
}
requestAnimationFrame(focus);
return true;
}

function finish(success, text, context) {
finished = true;
nodes.forEach(clearInput);
if (success) nodes.forEach(node => setPhase(node, 'complete'));
else {
const failed = nodes.find(node => node.id === context?.node_id) || nodes[Math.max(0, activeIndex)];
if (failed) setPhase(failed, 'failed');
}
message.textContent = text || '';
cancelButton.hidden = true;
backButton.hidden = success;
}

return { element, handle, focus, finish, destroy() { nodes.forEach(clearInput); element.remove(); finished = true; } };
};
})();
Loading
Loading