Label
@@ -214,7 +327,24 @@
@@ -263,11 +393,17 @@
{#snippet actions()}
-
- Cancel
-
+ {#if modal?.kind === "wizard-form"}
+
+ Back
+
+ {:else}
+
+ Cancel
+
+ {/if}
- {saving ? "Saving…" : modal?.kind === "create" ? "Add" : "Save"}
+ {saving ? "Saving…" : modal?.kind === "edit" ? "Save" : "Add"}
{/snippet}
@@ -283,11 +419,33 @@
letter-spacing: 0.05em;
}
- .header {
+ .register-section {
+ margin-top: var(--space-5);
display: flex;
align-items: center;
- justify-content: space-between;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+ }
+
+ /* Demo Endpoints section: headline + toggle clustered on the left so it
+ matches the rest of the settings pages where actions live left-aligned. */
+ .demo-section-header {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ margin-top: var(--space-6);
margin-bottom: 0.75rem;
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--border);
+ }
+
+ .demo-section-label {
+ margin: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
.row-label {
@@ -300,20 +458,58 @@
max-width: 100%;
}
- .hint-block {
- margin-top: var(--space-6);
+ .wizard-step-hint {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+ margin: 0 0 0.75rem;
+ }
+
+ /* Address input with an optional gray "shellwatch@" prefix adornment shown
+ after blur when the user didn't supply a custom username. The wrapper
+ mimics the input's chrome so the prefix + input look like one field. */
+ .address-input-wrap {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ }
+
+ .address-input-wrap.has-default-user {
+ border: 1px solid var(--outline-variant);
+ border-radius: 6px;
+ background-color: var(--surface-container);
+ overflow: hidden;
+ }
+
+ .address-input-wrap.has-default-user input {
+ border: none;
+ background: transparent;
+ padding-left: 0;
}
- .hint {
- font-size: 0.8rem;
+ .default-user-prefix {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.5rem 0 0.5rem 0.625rem;
color: var(--text-muted);
- line-height: 1.5;
+ font-family: var(--font-mono, monospace);
+ font-size: var(--body-md);
+ user-select: none;
+ pointer-events: none;
+ }
+
+ .address-warning {
+ display: block;
+ margin-top: 0.35rem;
+ font-size: 0.75rem;
+ color: var(--warning, var(--secondary, #f59e0b));
+ line-height: 1.4;
}
- .hint code {
+ .address-warning code {
font-family: var(--font-mono);
font-size: 0.85em;
- color: var(--primary);
}
.field {
diff --git a/client/src/routes/settings/keys/+page.svelte b/client/src/routes/settings/keys/+page.svelte
index 49ae400..39897d2 100644
--- a/client/src/routes/settings/keys/+page.svelte
+++ b/client/src/routes/settings/keys/+page.svelte
@@ -1,7 +1,6 @@
@@ -515,79 +480,19 @@
{/if}
{#if revokeTarget}
- {@const target = revokeTarget}
- Revoke {target.label} ? This is permanent and cannot be undone.
- {#if target.kind === "passkey"}
- You'll be asked to verify with another passkey first.
- {/if}
+ Revoke {revokeTarget.label} ? This is permanent and cannot be undone. You'll
+ be asked to verify with another passkey first.
{/if}
-
- {#if hasAuthorizedKeys}
-
-
SSH Server Setup
-
Add this line to /etc/ssh/sshd_config on your remote server:
-
PubkeyAcceptedAlgorithms=+webauthn-sk-ecdsa-sha2-nistp256@openssh.com
-
Then add the passkey's SSH public key to ~/.ssh/authorized_keys.
-
- {/if}
-
-
- {#if $account?.isAdmin && fileKeys.length > 0}
- File-Based SSH Keys
-
- {#each fileKeys as k (k.id)}
-
- {#snippet primary()}
- {k.label}
- {#if k.revoked}
- revoked
- {:else if !k.available}
- unavailable
- {:else}
- available
- {/if}
- {k.algorithm}
- {/snippet}
- {#snippet secondary()}
- {shortFingerprint(k.fingerprint)}
- · created {formatDate(k.createdAt)}
- · last used {formatDate(k.lastUsedAt)}
- {/snippet}
- {#snippet actions()}
- {#if k.authorizedKeysEntry && !k.revoked}
-
- copyKey(k.authorizedKeysEntry!, e.currentTarget as HTMLButtonElement)}
- >Copy SSH PubKey
- {/if}
- {#if !k.revoked}
- openRevokeFileKey(k.id, k.label)}>Revoke
- {/if}
- {/snippet}
-
- {/each}
-
- {/if}
diff --git a/client/src/routes/settings/ssh-keys/+page.svelte b/client/src/routes/settings/ssh-keys/+page.svelte
new file mode 100644
index 0000000..f40b75b
--- /dev/null
+++ b/client/src/routes/settings/ssh-keys/+page.svelte
@@ -0,0 +1,178 @@
+
+
+
+
+
+ File-Based SSH Keys
+
+
+ {#each fileKeys as k (k.id)}
+
+ {#snippet primary()}
+ {k.label}
+ {#if k.revoked}
+ revoked
+ {:else if !k.available}
+ unavailable
+ {:else}
+ available
+ {/if}
+ {k.algorithm}
+ {/snippet}
+ {#snippet secondary()}
+ {shortFingerprint(k.fingerprint)}
+ · created {formatDate(k.createdAt)}
+ · last used {formatDate(k.lastUsedAt)}
+ {/snippet}
+ {#snippet actions()}
+ {#if k.authorizedKeysEntry && !k.revoked}
+ copyKey(k.authorizedKeysEntry!, e.currentTarget as HTMLButtonElement)}
+ >Copy SSH PubKey
+ {/if}
+ {#if !k.revoked}
+ openRevoke(k.id, k.label)}>Revoke
+ {/if}
+ {/snippet}
+
+ {/each}
+
+
+ {#if revokeTarget}
+
+
+ Revoke {revokeTarget.label} ? This is permanent and cannot be undone.
+
+
+ {/if}
+
+
+
diff --git a/config.sample.yaml b/config.sample.yaml
index dd5b3c1..cd52ac8 100644
--- a/config.sample.yaml
+++ b/config.sample.yaml
@@ -41,14 +41,35 @@ keyDirectory: ./keys
# Optional: seed endpoints for the admin account on first run (keys are assigned via UI)
# Format: [user@]host[:port] — defaults: user=shellwatch, port=22
# agentForward defaults to true; set to false for hosts that disallow forwarding.
+# description is optional free-form context (max 1000 chars) surfaced to MCP
+# agents via the shellwatch_manage_endpoints list/read tool.
# seedAdminEndpoints:
# - label: Dev Box
# address: ubuntu@dev.example.com
+# description: "Personal dev sandbox. /srv/app holds the staging copy."
#
# - label: Staging
# address: deploy@staging.example.com:2222
# agentForward: false
+# Optional: virtual demo endpoints merged into every account's endpoint list.
+# Same shape as seedAdminEndpoints (including description). Never copied into
+# the database — config is the source of truth. Each account has a "Show demo
+# endpoints" toggle on the Endpoints page (default on); set demoEndpoints to
+# [] or omit to disable. Pairs with the rado0x54/shellwatch-demo-server
+# container — see issue #211.
+# demoEndpoints:
+# - label: "Demo: Sudoku"
+# address: sw-sudoku@ssh.shellwatch.ai
+# description: "Terminal sudoku (nudoku). ForceCommand-pinned; no shell."
+# - label: "Demo: 2048"
+# address: sw-2048@ssh.shellwatch.ai
+# description: "Terminal 2048. ForceCommand-pinned; no shell."
+# - label: "Demo: Snake"
+# address: sw-snake@ssh.shellwatch.ai
+# - label: "Demo: Matrix"
+# address: sw-matrix@ssh.shellwatch.ai
+
# Security settings
security:
# WebAuthn Relying Party ID — must match the domain passkeys are registered on.
diff --git a/drizzle/0008_account_show_demo_endpoints.sql b/drizzle/0008_account_show_demo_endpoints.sql
new file mode 100644
index 0000000..a1da434
--- /dev/null
+++ b/drizzle/0008_account_show_demo_endpoints.sql
@@ -0,0 +1 @@
+ALTER TABLE `accounts` ADD `show_demo_endpoints` integer DEFAULT true NOT NULL;
\ No newline at end of file
diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json
new file mode 100644
index 0000000..9593351
--- /dev/null
+++ b/drizzle/meta/0008_snapshot.json
@@ -0,0 +1,981 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "51088beb-e89a-4425-a71a-9509a1c5483a",
+ "prevId": "833d8ef2-d304-4fca-a662-6c996f63b68d",
+ "tables": {
+ "accounts": {
+ "name": "accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "max_sessions": {
+ "name": "max_sessions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "show_demo_endpoints": {
+ "name": "show_demo_endpoints",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "admin_account": {
+ "name": "admin_account",
+ "columns": {
+ "singleton": {
+ "name": "singleton",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "admin_account_account_id_accounts_id_fk": {
+ "name": "admin_account_account_id_accounts_id_fk",
+ "tableFrom": "admin_account",
+ "tableTo": "accounts",
+ "columnsFrom": ["account_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "single_row": {
+ "name": "single_row",
+ "value": "\"admin_account\".\"singleton\" = 1"
+ }
+ }
+ },
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_prefix": {
+ "name": "key_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoints": {
+ "name": "endpoints",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "columns": ["key_hash"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "api_keys_account_id_accounts_id_fk": {
+ "name": "api_keys_account_id_accounts_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "accounts",
+ "columnsFrom": ["account_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_session_lifecycle": {
+ "name": "audit_session_lifecycle",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint_id": {
+ "name": "endpoint_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_ip": {
+ "name": "source_ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_reason": {
+ "name": "mcp_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_client_name": {
+ "name": "mcp_client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_client_version": {
+ "name": "mcp_client_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "api_key_label": {
+ "name": "api_key_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "api_key_prefix": {
+ "name": "api_key_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_hostname": {
+ "name": "client_hostname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_os": {
+ "name": "client_os",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_version": {
+ "name": "client_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "close_reason": {
+ "name": "close_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_session_lifecycle_account_created_idx": {
+ "name": "audit_session_lifecycle_account_created_idx",
+ "columns": ["account_id", "created_at", "session_id"],
+ "isUnique": false
+ },
+ "audit_session_lifecycle_account_endpoint_created_idx": {
+ "name": "audit_session_lifecycle_account_endpoint_created_idx",
+ "columns": ["account_id", "endpoint_id", "created_at", "session_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_session_lifecycle_account_id_accounts_id_fk": {
+ "name": "audit_session_lifecycle_account_id_accounts_id_fk",
+ "tableFrom": "audit_session_lifecycle",
+ "tableTo": "accounts",
+ "columnsFrom": ["account_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "audit_session_lifecycle_status_chk": {
+ "name": "audit_session_lifecycle_status_chk",
+ "value": "\"audit_session_lifecycle\".\"status\" IN ('open','closed','error')"
+ },
+ "audit_session_lifecycle_source_chk": {
+ "name": "audit_session_lifecycle_source_chk",
+ "value": "\"audit_session_lifecycle\".\"source\" IN ('ui','mcp','ssh')"
+ }
+ }
+ },
+ "audit_signing_requests": {
+ "name": "audit_signing_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "latency_ms": {
+ "name": "latency_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_ip": {
+ "name": "source_ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "endpoint_label": {
+ "name": "endpoint_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "endpoint_address": {
+ "name": "endpoint_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_reason": {
+ "name": "mcp_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_client_name": {
+ "name": "mcp_client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_client_version": {
+ "name": "mcp_client_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "api_key_label": {
+ "name": "api_key_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "api_key_prefix": {
+ "name": "api_key_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_hostname": {
+ "name": "client_hostname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_os": {
+ "name": "client_os",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_version": {
+ "name": "client_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "passkey_label": {
+ "name": "passkey_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_verification": {
+ "name": "user_verification",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_label": {
+ "name": "key_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_signing_requests_account_created_idx": {
+ "name": "audit_signing_requests_account_created_idx",
+ "columns": ["account_id", "created_at", "id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_signing_requests_account_id_accounts_id_fk": {
+ "name": "audit_signing_requests_account_id_accounts_id_fk",
+ "tableFrom": "audit_signing_requests",
+ "tableTo": "accounts",
+ "columnsFrom": ["account_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "audit_signing_requests_type_chk": {
+ "name": "audit_signing_requests_type_chk",
+ "value": "\"audit_signing_requests\".\"type\" IN ('webauthn-sign','key-approve')"
+ },
+ "audit_signing_requests_source_chk": {
+ "name": "audit_signing_requests_source_chk",
+ "value": "\"audit_signing_requests\".\"source\" IN ('endpoint-auth','agent-forwarding','agent-proxy')"
+ },
+ "audit_signing_requests_outcome_chk": {
+ "name": "audit_signing_requests_outcome_chk",
+ "value": "\"audit_signing_requests\".\"outcome\" IS NULL OR \"audit_signing_requests\".\"outcome\" IN ('approved','denied','expired','cancelled')"
+ }
+ }
+ },
+ "endpoints": {
+ "name": "endpoints",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host": {
+ "name": "host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 22
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "user_verification": {
+ "name": "user_verification",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'required'"
+ },
+ "agent_forward": {
+ "name": "agent_forward",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "endpoints_account_id_accounts_id_fk": {
+ "name": "endpoints_account_id_accounts_id_fk",
+ "tableFrom": "endpoints",
+ "tableTo": "accounts",
+ "columnsFrom": ["account_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "push_subscriptions": {
+ "name": "push_subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "p256dh": {
+ "name": "p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth": {
+ "name": "auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "push_subscriptions_endpoint_unique": {
+ "name": "push_subscriptions_endpoint_unique",
+ "columns": ["endpoint"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "push_subscriptions_account_id_accounts_id_fk": {
+ "name": "push_subscriptions_account_id_accounts_id_fk",
+ "tableFrom": "push_subscriptions",
+ "tableTo": "accounts",
+ "columnsFrom": ["account_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ssh_keys": {
+ "name": "ssh_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'file'"
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ssh_keys_fingerprint_unique": {
+ "name": "ssh_keys_fingerprint_unique",
+ "columns": ["fingerprint"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webauthn_credentials": {
+ "name": "webauthn_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key_openssh": {
+ "name": "public_key_openssh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webauthn_credentials_credential_id_unique": {
+ "name": "webauthn_credentials_credential_id_unique",
+ "columns": ["credential_id"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "webauthn_credentials_account_id_accounts_id_fk": {
+ "name": "webauthn_credentials_account_id_accounts_id_fk",
+ "tableFrom": "webauthn_credentials",
+ "tableTo": "accounts",
+ "columnsFrom": ["account_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 68785a3..3ed77e2 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -57,6 +57,13 @@
"when": 1779047498048,
"tag": "0007_endpoint_agent_forward",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "6",
+ "when": 1779052745016,
+ "tag": "0008_account_show_demo_endpoints",
+ "breakpoints": true
}
]
}
diff --git a/src/agent/agent-session.test.ts b/src/agent/agent-session.test.ts
index 3fbd637..cbfd9ba 100644
--- a/src/agent/agent-session.test.ts
+++ b/src/agent/agent-session.test.ts
@@ -1,10 +1,18 @@
// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
+import { StubAccountRepository } from "../db/repositories/account-repo.js";
import { InMemoryEndpointRepository } from "../db/repositories/endpoint-repo.js";
+import { createDemoEndpointsService } from "../demo-endpoints/index.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { AgentSession } from "./agent-session.js";
+// Demo-aware deps that mirror the production wiring but with empty demo
+// config / a no-op account repo. Tests that need the merged list with
+// actual demos should pass their own service instead.
+const EMPTY_DEMO = createDemoEndpointsService([]);
+const NO_OP_ACCOUNT = new StubAccountRepository();
+
function createMockTerminalManager() {
const emitter = new EventEmitter();
return Object.assign(emitter, {
@@ -45,6 +53,8 @@ describe("AgentSession.listEndpoints", () => {
]);
const session = new AgentSession({
endpointRepo,
+ demoEndpoints: EMPTY_DEMO,
+ accountRepo: NO_OP_ACCOUNT,
terminalManager: createMockTerminalManager(),
source: "mcp",
accountId: "account-alice",
@@ -75,6 +85,8 @@ describe("AgentSession.createSession", () => {
const terminalManager = createMockTerminalManager();
const session = new AgentSession({
endpointRepo,
+ demoEndpoints: EMPTY_DEMO,
+ accountRepo: NO_OP_ACCOUNT,
terminalManager,
source: "mcp",
accountId: "account-bob",
@@ -109,6 +121,8 @@ describe("AgentSession.createSession", () => {
});
const session = new AgentSession({
endpointRepo,
+ demoEndpoints: EMPTY_DEMO,
+ accountRepo: NO_OP_ACCOUNT,
terminalManager,
source: "mcp",
accountId: "account-alice",
diff --git a/src/agent/agent-session.ts b/src/agent/agent-session.ts
index 109f3f2..5068375 100644
--- a/src/agent/agent-session.ts
+++ b/src/agent/agent-session.ts
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
+import type { AccountRepository } from "../db/repositories/account-repo.js";
import type { EndpointRepository } from "../db/repositories/endpoint-repo.js";
+import type { DemoEndpointsService } from "../demo-endpoints/index.js";
+import { isDemoEndpointId } from "../demo-endpoints/index.js";
import type { EndpointAuthTrigger } from "../pending-action/types.js";
import type { OutputReadResult, TerminalManager, TerminalSession } from "../terminal/index.js";
import { resolveKeys } from "../terminal/keys.js";
@@ -9,6 +12,16 @@ export type AgentSource = "mcp" | "ssh";
export interface AgentSessionOptions {
endpointRepo: EndpointRepository;
+ /**
+ * Service that materializes the operator-configured demo endpoints. Merged
+ * into the agent's endpoint listing when the account's showDemoEndpoints
+ * toggle is on; opens are accepted on `demo:*` ids regardless of the toggle
+ * (matches the UI flow — visibility hides them, but the connect path stays
+ * usable for callers that already know the id).
+ */
+ demoEndpoints: DemoEndpointsService;
+ /** Used to read the account's `showDemoEndpoints` toggle at list time. */
+ accountRepo: AccountRepository;
terminalManager: TerminalManager;
source: AgentSource;
/**
@@ -38,6 +51,8 @@ export class AgentSession {
private mcpClientVersion?: string;
private readonly endpointRepo: EndpointRepository;
+ private readonly demoEndpoints: DemoEndpointsService;
+ private readonly accountRepo: AccountRepository;
private readonly terminalManager: TerminalManager;
private readonly source: AgentSource;
private readonly accountId: string;
@@ -48,6 +63,8 @@ export class AgentSession {
constructor(opts: AgentSessionOptions) {
this.endpointRepo = opts.endpointRepo;
+ this.demoEndpoints = opts.demoEndpoints;
+ this.accountRepo = opts.accountRepo;
this.terminalManager = opts.terminalManager;
this.source = opts.source;
this.accountId = opts.accountId;
@@ -83,8 +100,12 @@ export class AgentSession {
description: string | null;
}[]
> {
- const endpoints = await this.endpointRepo.findAllForAccount(this.accountId);
- return endpoints.map(({ id, label, host, port, username, description }) => ({
+ const own = await this.endpointRepo.findAllForAccount(this.accountId);
+ const account = await this.accountRepo.findById(this.accountId);
+ const merged = account?.showDemoEndpoints
+ ? [...own, ...this.demoEndpoints.list(this.accountId)]
+ : own;
+ return merged.map(({ id, label, host, port, username, description }) => ({
id,
label,
host,
@@ -101,7 +122,12 @@ export class AgentSession {
// could pass any endpoint UUID and trigger a WebAuthn approval prompt on
// the owning account with attacker-chosen reason text — and, if approved,
// drive the resulting session via send_keys / read_output.
- const endpoint = await this.endpointRepo.findByIdForAccount(endpointId, this.accountId);
+ //
+ // Demo endpoints aren't account-scoped (they're a global config set), so
+ // route them through the synthesizer instead of the per-account repo.
+ const endpoint = isDemoEndpointId(endpointId)
+ ? this.demoEndpoints.findById(endpointId, this.accountId)
+ : await this.endpointRepo.findByIdForAccount(endpointId, this.accountId);
if (!endpoint) {
throw new Error(`Unknown endpoint: ${endpointId}`);
}
diff --git a/src/config/index.ts b/src/config/index.ts
index 96b8b9c..4c82f58 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -3,6 +3,8 @@ export { loadConfig } from "./loader.js";
export {
type Config,
ConfigSchema,
+ type DemoEndpoint,
+ DemoEndpointSchema,
type SeedEndpoint,
SeedEndpointSchema,
securityFieldDefaults,
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 7234e96..a0758d0 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -13,8 +13,21 @@ export const SeedEndpointSchema = z.object({
* Defaults to true — opt out per endpoint when the host disallows forwarding.
*/
agentForward: z.boolean().default(true),
+ /**
+ * Optional free-form context. Surfaced to MCP agents via the
+ * `shellwatch_manage_endpoints` list/read tool — the canonical place to
+ * tell an agent what a given host is for. Bounded to the same 1000-char
+ * cap the REST API enforces on user-created endpoints.
+ */
+ description: z.string().max(1000).optional(),
});
+// demoEndpoints uses the same shape as seedAdminEndpoints — they're virtual,
+// global endpoints merged into every account's endpoint list (toggle on the
+// account row). See src/demo-endpoints/. Key delivery is a separate concern,
+// not modeled per-endpoint.
+export const DemoEndpointSchema = SeedEndpointSchema;
+
/** Field-level defaults for optional security settings (rpId and trustedWebauthnOrigins are required) */
export const rateLimitDefaults = {
selfRegister: { max: 5, windowMinutes: 15 },
@@ -161,6 +174,12 @@ export const ConfigSchema = z.object({
seedAdminEndpoints: z.array(SeedEndpointSchema).default([]),
seedAdminApiKey: z.string().optional(),
seedAdminPasskeys: z.array(SeedAdminPasskeySchema).default([]),
+ /**
+ * Virtual demo endpoints merged into every account's endpoint list when the
+ * account's showDemoEndpoints toggle is on. Same shape as seedAdminEndpoints
+ * but never copied into the endpoints table — config is the source of truth.
+ */
+ demoEndpoints: z.array(DemoEndpointSchema).default([]),
server: ServerSchema,
security: SecuritySchema,
notifications: NotificationsSchema.default(notificationDefaults),
@@ -169,4 +188,5 @@ export const ConfigSchema = z.object({
});
export type SeedEndpoint = z.infer
;
+export type DemoEndpoint = z.infer;
export type Config = z.infer;
diff --git a/src/db/repositories/account-repo.ts b/src/db/repositories/account-repo.ts
index 5d5bac2..f4f1b14 100644
--- a/src/db/repositories/account-repo.ts
+++ b/src/db/repositories/account-repo.ts
@@ -9,6 +9,7 @@ export interface AccountInfo {
isAdmin: boolean;
enabled: boolean;
maxSessions: number;
+ showDemoEndpoints: boolean;
lastUsedAt: string | null;
createdAt: string;
updatedAt: string;
@@ -19,7 +20,7 @@ export interface AccountRepository {
findAll(): Promise;
update(
id: string,
- data: Partial>,
+ data: Partial>,
): Promise;
/** Mark account as active. Writes are batched — call flushLastUsed() to persist. */
touchLastUsed(id: string): void;
@@ -81,7 +82,7 @@ export class DrizzleAccountRepository implements AccountRepository {
async update(
id: string,
- data: Partial>,
+ data: Partial>,
): Promise {
this.db
.update(accounts)
diff --git a/src/db/schema.ts b/src/db/schema.ts
index adfd95a..cc3fe36 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -10,6 +10,11 @@ export const accounts = sqliteTable("accounts", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
maxSessions: integer("max_sessions").notNull().default(5),
+ // Whether the operator-configured demoEndpoints are merged into this
+ // account's endpoint list. Default true; user can hide them from the
+ // Endpoints page. Demo endpoints are never copied into the `endpoints`
+ // table — config is the source of truth. See src/demo-endpoints/.
+ showDemoEndpoints: integer("show_demo_endpoints", { mode: "boolean" }).notNull().default(true),
lastUsedAt: text("last_used_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
diff --git a/src/db/seed.ts b/src/db/seed.ts
index a685be5..310a829 100644
--- a/src/db/seed.ts
+++ b/src/db/seed.ts
@@ -106,6 +106,7 @@ export function seedFromConfig(db: ShellWatchDB, config: Config): SeedResult {
port: ep.address.port,
username: ep.address.username,
agentForward: ep.agentForward,
+ description: ep.description ?? null,
enabled: true,
createdAt: now,
updatedAt: now,
diff --git a/src/demo-endpoints/index.test.ts b/src/demo-endpoints/index.test.ts
new file mode 100644
index 0000000..c523f6b
--- /dev/null
+++ b/src/demo-endpoints/index.test.ts
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
+import { describe, expect, it } from "vitest";
+import { ConfigSchema } from "../config/index.js";
+import { DEMO_ENDPOINT_ID_PREFIX, createDemoEndpointsService, isDemoEndpointId } from "./index.js";
+
+function buildConfig(yaml: { demoEndpoints?: unknown }) {
+ return ConfigSchema.parse({
+ server: { externalUrl: "http://localhost:3000" },
+ security: {
+ rpId: "localhost",
+ trustedWebauthnOrigins: ["http://localhost:3000"],
+ },
+ ...yaml,
+ });
+}
+
+describe("isDemoEndpointId", () => {
+ it("matches the demo prefix", () => {
+ expect(isDemoEndpointId(`${DEMO_ENDPOINT_ID_PREFIX}abc123`)).toBe(true);
+ expect(isDemoEndpointId("not-a-demo-id")).toBe(false);
+ expect(isDemoEndpointId("")).toBe(false);
+ });
+});
+
+describe("createDemoEndpointsService", () => {
+ it("returns an empty list when config has no demo endpoints", () => {
+ const cfg = buildConfig({});
+ const svc = createDemoEndpointsService(cfg.demoEndpoints);
+ expect(svc.list("acc1")).toEqual([]);
+ expect(svc.isEmpty()).toBe(true);
+ });
+
+ it("synthesizes endpoints with stable demo: ids and accountId set", () => {
+ const cfg = buildConfig({
+ demoEndpoints: [
+ { label: "Demo: Sudoku", address: "sw-sudoku@ssh.shellwatch.ai" },
+ { label: "Demo: 2048", address: "sw-2048@ssh.shellwatch.ai" },
+ ],
+ });
+ const svc = createDemoEndpointsService(cfg.demoEndpoints);
+ const list = svc.list("acc-42");
+ expect(list).toHaveLength(2);
+ for (const e of list) {
+ expect(e.id.startsWith(DEMO_ENDPOINT_ID_PREFIX)).toBe(true);
+ expect(e.accountId).toBe("acc-42");
+ expect(e.userVerification).toBe("required");
+ expect(e.description).toBeNull();
+ }
+ // Different addresses produce distinct ids.
+ expect(list[0].id).not.toBe(list[1].id);
+ });
+
+ it("preserves the agentForward flag from config", () => {
+ const cfg = buildConfig({
+ demoEndpoints: [
+ { label: "demo-on", address: "alice@host-a" },
+ { label: "demo-off", address: "bob@host-b", agentForward: false },
+ ],
+ });
+ const svc = createDemoEndpointsService(cfg.demoEndpoints);
+ const list = svc.list("acc1");
+ expect(list[0].agentForward).toBe(true);
+ expect(list[1].agentForward).toBe(false);
+ });
+
+ it("findById resolves a known demo id, scoped to the requesting account", () => {
+ const cfg = buildConfig({
+ demoEndpoints: [{ label: "Demo: Snake", address: "sw-snake@ssh.shellwatch.ai" }],
+ });
+ const svc = createDemoEndpointsService(cfg.demoEndpoints);
+ const [synthesized] = svc.list("acc1");
+ const found = svc.findById(synthesized.id, "acc-99");
+ expect(found).not.toBeNull();
+ expect(found!.accountId).toBe("acc-99");
+ expect(found!.host).toBe("ssh.shellwatch.ai");
+ expect(found!.username).toBe("sw-snake");
+ });
+
+ it("findById returns null for unknown or non-demo ids", () => {
+ const cfg = buildConfig({
+ demoEndpoints: [{ label: "Demo: Snake", address: "sw-snake@ssh.shellwatch.ai" }],
+ });
+ const svc = createDemoEndpointsService(cfg.demoEndpoints);
+ expect(svc.findById("some-uuid", "acc1")).toBeNull();
+ expect(svc.findById(`${DEMO_ENDPOINT_ID_PREFIX}deadbeef`, "acc1")).toBeNull();
+ });
+
+ it("produces a stable id across reconstructions with the same config", () => {
+ const cfg = buildConfig({
+ demoEndpoints: [{ label: "Demo: Sudoku", address: "sw-sudoku@ssh.shellwatch.ai" }],
+ });
+ const a = createDemoEndpointsService(cfg.demoEndpoints).list("acc1")[0].id;
+ const b = createDemoEndpointsService(cfg.demoEndpoints).list("acc1")[0].id;
+ expect(a).toBe(b);
+ });
+});
diff --git a/src/demo-endpoints/index.ts b/src/demo-endpoints/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1366ace7ef0c8579134336e928f3e9f41afa2bcf
GIT binary patch
literal 2556
zcmaJ@?QYvP6y0w>#hp>0a$w6%u=R(tH0YKDGtf3c(sc!bAfZLd;wqCGNhR?z1AB-)
z;htoNq$JB&v;2rn>VBSk?vbZcy1F>|Jb5QGp{$t9xlm5#QkWSvcb6icoL#+}?5F#a
zw-w6@F*!(I4dklQ#?cdHMldISX11UW!ms6>@cuaq@|M6>m<|@~HKfah6QE4lkHd5HBXkt(NZ45LJkD*qiu#Qen
z-=E)}emJ=}pMUsxJ3qO-xI8_Zf2JcEa@00M=GGyvRpz8tByGR!IZq^?k#$C@u<8PxrdEKs+fY9;y7pnXG>D~h!M?{J{q)u@o;jQ_75m47;JP-3ueXZ?`l&X
z!nQ1>^=%qyzYso?LR&W`58BvAtF17Kt;7gGkP^#ME~PIx*M@wN_xmV(rO67H4m!~M
zRW~&rVz!{g*fUb~Y9Y+HOVBLwL&3u2EE7B2w^ssl#HDA=bZ?KY)(Ti!`3HuDdANWsqa|jI$%x-;lnMxlWtpy(
zOX+k~xivi$cpS&=rz8AjN_$g8^-+?>eBLK)Hx=yC^^I@P7jP-8F7H7(x(kc2cSrZq
zxSExonG2SE@gxGz`|e``2UJ~_O-`V|e_Zcu$ha-}*&6825TY&Qtr4D9_|McjGOOKc
z+q7C6pu`-pvOJE>N*nM<}BiffRIfw%vS^KC|r%
z!(m*ja=kO(>l;+^OycXCzEO-M2)?E^Z2xI{7o$QpJl3?G958z6C}YN~
zsS;+=KA~Se1!2R;*Dw9hDVL9ecVRS)8Pt7})%e8RAv1@*f}lMXx&(M~R+pfI_1+N1
zqRX3xPC_*{GqUn}K8{;c!4R)INM^&TG3BLxfY=sB*UQ4w-OJnipDx-Uex+{P!eI#I
zCyS+6B9292v8JwKTUS*nQ8E_PiASho3*ILPeJ&x*C}&1Pzuyv^9{Lu7-o^Gq;4^I=
zm;nngBcG@_M++{xw!A^_NXN%vt-Rf46kj%tBz2^YGTL8?R+kcZ?J)CMo{tA$`qx7A
z#Cy9u99y?N?XFT^Ya{n}C2SNTdI+1>>^2bJT^vf&RP=-=0^mJ25w@OA^~|;jWKPMq
zUwQ41Ms02{dS0>oU-O27F7LL-Q=g~ZXOTijkB*M8(V {
});
});
+ // Demo-endpoint visibility/mutation behavior via MCP. Locks in the contract
+ // that the toggle controls listing only, mutations are refused, and the
+ // synthesized ids are resolvable via read regardless of toggle state.
+ describe("shellwatch_manage_endpoints (demo endpoints)", () => {
+ async function setupDemoClient(opts: { showDemoEndpoints: boolean }) {
+ const endpointRepo = new InMemoryEndpointRepository(testEndpoints);
+ const keyRepo = new InMemorySshKeyRepository(testKeys);
+ const demoEndpoints = createDemoEndpointsService([
+ {
+ label: "Demo: 2048",
+ address: { host: "ssh.example.com", port: 22, username: "sw-2048" },
+ agentForward: false,
+ },
+ ]);
+ const now = new Date().toISOString();
+ const accountRepo: typeof NO_OP_ACCOUNT = {
+ async findById() {
+ return {
+ id: testAccountId,
+ name: "test",
+ isAdmin: false,
+ enabled: true,
+ maxSessions: 5,
+ showDemoEndpoints: opts.showDemoEndpoints,
+ lastUsedAt: null,
+ createdAt: now,
+ updatedAt: now,
+ };
+ },
+ async findAll() {
+ return [];
+ },
+ async update() {},
+ touchLastUsed() {},
+ flushLastUsed() {},
+ getAdminAccountId() {
+ return null;
+ },
+ setAdmin() {},
+ isAdmin() {
+ return false;
+ },
+ destroy() {},
+ };
+ const agentSession = new AgentSession({
+ endpointRepo,
+ demoEndpoints,
+ accountRepo,
+ terminalManager: mockManager,
+ source: "mcp",
+ accountId: testAccountId,
+ });
+ const mcpServer = await createMcpServer({
+ agentSession,
+ endpointRepo,
+ demoEndpoints,
+ accountRepo,
+ keyRepo,
+ accountId: testAccountId,
+ });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ await mcpServer.connect(serverTransport);
+ const client = new Client({ name: "test-client", version: "1.0.0" });
+ await client.connect(clientTransport);
+ const demoId = demoEndpoints.list(testAccountId)[0].id;
+ return { client, demoId };
+ }
+
+ it("merges demo endpoints into list when the toggle is on", async () => {
+ const { client, demoId } = await setupDemoClient({ showDemoEndpoints: true });
+ const result = await client.callTool({
+ name: "shellwatch_manage_endpoints",
+ arguments: { action: "list" },
+ });
+ const content = (result.content as { type: string; text: string }[])[0].text;
+ const parsed = JSON.parse(content);
+ const ids = (parsed.endpoints as { id: string }[]).map((e) => e.id);
+ expect(ids).toContain("dev-box");
+ expect(ids).toContain(demoId);
+ });
+
+ it("omits demo endpoints from list when the toggle is off", async () => {
+ const { client } = await setupDemoClient({ showDemoEndpoints: false });
+ const result = await client.callTool({
+ name: "shellwatch_manage_endpoints",
+ arguments: { action: "list" },
+ });
+ const content = (result.content as { type: string; text: string }[])[0].text;
+ const parsed = JSON.parse(content);
+ const ids = (parsed.endpoints as { id: string }[]).map((e) => e.id);
+ expect(ids).toEqual(["dev-box"]);
+ });
+
+ it("read resolves demo:* ids regardless of the toggle state", async () => {
+ const { client, demoId } = await setupDemoClient({ showDemoEndpoints: false });
+ const result = await client.callTool({
+ name: "shellwatch_manage_endpoints",
+ arguments: { action: "read", id: demoId },
+ });
+ expect(result.isError).toBeUndefined();
+ const content = (result.content as { type: string; text: string }[])[0].text;
+ const parsed = JSON.parse(content);
+ expect(parsed.id).toBe(demoId);
+ expect(parsed.host).toBe("ssh.example.com");
+ });
+
+ it("rejects update on demo:* ids", async () => {
+ const { client, demoId } = await setupDemoClient({ showDemoEndpoints: true });
+ const result = await client.callTool({
+ name: "shellwatch_manage_endpoints",
+ arguments: { action: "update", id: demoId, data: { label: "x" } },
+ });
+ expect(result.isError).toBe(true);
+ const text = (result.content as { type: string; text: string }[])[0].text;
+ expect(text).toMatch(/read-only/i);
+ });
+
+ it("rejects delete on demo:* ids", async () => {
+ const { client, demoId } = await setupDemoClient({ showDemoEndpoints: true });
+ const result = await client.callTool({
+ name: "shellwatch_manage_endpoints",
+ arguments: { action: "delete", id: demoId },
+ });
+ expect(result.isError).toBe(true);
+ const text = (result.content as { type: string; text: string }[])[0].text;
+ expect(text).toMatch(/read-only/i);
+ });
+
+ it("rejects create with a demo:* id", async () => {
+ const { client } = await setupDemoClient({ showDemoEndpoints: true });
+ const result = await client.callTool({
+ name: "shellwatch_manage_endpoints",
+ arguments: {
+ action: "create",
+ id: "demo:fakehash",
+ data: { label: "x", host: "h", username: "u" },
+ },
+ });
+ expect(result.isError).toBe(true);
+ const text = (result.content as { type: string; text: string }[])[0].text;
+ expect(text).toMatch(/read-only/i);
+ });
+ });
+
describe("shellwatch_manage_keys", () => {
it("lists keys", async () => {
const client = await setupClient(mockManager);
@@ -152,11 +313,20 @@ describe("MCP Server Tools", () => {
const keyRepo = new InMemorySshKeyRepository(testKeys);
const agentSession = new AgentSession({
endpointRepo,
+ demoEndpoints: EMPTY_DEMO,
+ accountRepo: NO_OP_ACCOUNT,
terminalManager: mockManager,
source: "mcp",
accountId: testAccountId,
});
- const mcpServer = await createMcpServer(agentSession, endpointRepo, keyRepo, testAccountId);
+ const mcpServer = await createMcpServer({
+ agentSession,
+ endpointRepo,
+ demoEndpoints: EMPTY_DEMO,
+ accountRepo: NO_OP_ACCOUNT,
+ keyRepo,
+ accountId: testAccountId,
+ });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.connect(serverTransport);
const client = new Client({ name: "evil\nclient\x00", version: "9.9.9" });
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index f562475..0898471 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -1,19 +1,26 @@
// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { AgentSession } from "../agent/index.js";
+import type { AccountRepository } from "../db/repositories/account-repo.js";
import type { EndpointRepository } from "../db/repositories/endpoint-repo.js";
import type { SshKeyRepository } from "../db/repositories/key-repo.js";
+import type { DemoEndpointsService } from "../demo-endpoints/index.js";
import { buildInfo } from "../server/buildInfo.js";
import { registerEndpointTools } from "./tools/endpoints.js";
import { registerKeyTools } from "./tools/keys.js";
import { registerSessionTools } from "./tools/sessions.js";
-export async function createMcpServer(
- agentSession: AgentSession,
- endpointRepo: EndpointRepository,
- keyRepo: SshKeyRepository,
- accountId: string,
-): Promise {
+export interface CreateMcpServerParams {
+ agentSession: AgentSession;
+ endpointRepo: EndpointRepository;
+ demoEndpoints: DemoEndpointsService;
+ accountRepo: AccountRepository;
+ keyRepo: SshKeyRepository;
+ accountId: string;
+}
+
+export async function createMcpServer(params: CreateMcpServerParams): Promise {
+ const { agentSession, endpointRepo, demoEndpoints, accountRepo, keyRepo, accountId } = params;
const endpoints = await agentSession.listEndpoints();
const endpointList = endpoints
.map((s) => {
@@ -82,7 +89,7 @@ export async function createMcpServer(
};
registerSessionTools(mcpServer, agentSession);
- registerEndpointTools(mcpServer, endpointRepo, accountId);
+ registerEndpointTools(mcpServer, { endpointRepo, demoEndpoints, accountRepo, accountId });
registerKeyTools(mcpServer, keyRepo);
return mcpServer;
diff --git a/src/mcp/tools/endpoints.ts b/src/mcp/tools/endpoints.ts
index aac20ad..103e5dc 100644
--- a/src/mcp/tools/endpoints.ts
+++ b/src/mcp/tools/endpoints.ts
@@ -1,16 +1,27 @@
// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
+import type { AccountRepository } from "../../db/repositories/account-repo.js";
import {
ENDPOINT_DESCRIPTION_MAX_LENGTH,
type EndpointRepository,
} from "../../db/repositories/endpoint-repo.js";
+import type { DemoEndpointsService } from "../../demo-endpoints/index.js";
+import { isDemoEndpointId } from "../../demo-endpoints/index.js";
+
+export interface EndpointToolDeps {
+ endpointRepo: EndpointRepository;
+ demoEndpoints: DemoEndpointsService;
+ accountRepo: AccountRepository;
+ accountId: string;
+}
+
+const DEMO_READ_ONLY_ERROR =
+ "Demo endpoints are read-only operator-configured entries. Pick one of the account's own endpoints instead.";
+
+export function registerEndpointTools(mcpServer: McpServer, deps: EndpointToolDeps) {
+ const { endpointRepo, demoEndpoints, accountRepo, accountId } = deps;
-export function registerEndpointTools(
- mcpServer: McpServer,
- endpointRepo: EndpointRepository,
- accountId: string,
-) {
mcpServer.tool(
"shellwatch_manage_endpoints",
"Manage SSH endpoints. Actions: list, read, create, update, delete.",
@@ -39,8 +50,12 @@ export function registerEndpointTools(
try {
switch (action) {
case "list": {
- const all = await endpointRepo.findAllForAccount(accountId);
- const result = all.map(({ id, label, host, port, username, description }) => ({
+ const own = await endpointRepo.findAllForAccount(accountId);
+ const account = await accountRepo.findById(accountId);
+ const merged = account?.showDemoEndpoints
+ ? [...own, ...demoEndpoints.list(accountId)]
+ : own;
+ const result = merged.map(({ id, label, host, port, username, description }) => ({
id,
label,
host,
@@ -54,7 +69,12 @@ export function registerEndpointTools(
}
case "read": {
if (!id) return { isError: true, content: [{ type: "text", text: "id is required" }] };
- const ep = await endpointRepo.findByIdForAccount(id, accountId);
+ // Demo endpoints live outside the per-account table — resolve them
+ // via the synthesizer regardless of toggle state so an agent that
+ // already knows the id can still inspect it.
+ const ep = isDemoEndpointId(id)
+ ? demoEndpoints.findById(id, accountId)
+ : await endpointRepo.findByIdForAccount(id, accountId);
if (!ep)
return {
isError: true,
@@ -71,6 +91,9 @@ export function registerEndpointTools(
],
};
}
+ if (isDemoEndpointId(id)) {
+ return { isError: true, content: [{ type: "text", text: DEMO_READ_ONLY_ERROR }] };
+ }
await endpointRepo.create({
id,
accountId,
@@ -88,11 +111,17 @@ export function registerEndpointTools(
isError: true,
content: [{ type: "text", text: "id and data are required" }],
};
+ if (isDemoEndpointId(id)) {
+ return { isError: true, content: [{ type: "text", text: DEMO_READ_ONLY_ERROR }] };
+ }
await endpointRepo.update(id, accountId, data);
return { content: [{ type: "text", text: JSON.stringify({ status: "updated", id }) }] };
}
case "delete": {
if (!id) return { isError: true, content: [{ type: "text", text: "id is required" }] };
+ if (isDemoEndpointId(id)) {
+ return { isError: true, content: [{ type: "text", text: DEMO_READ_ONLY_ERROR }] };
+ }
await endpointRepo.delete(id, accountId);
return { content: [{ type: "text", text: JSON.stringify({ status: "deleted", id }) }] };
}
diff --git a/src/server/app.ts b/src/server/app.ts
index 5659c1c..2c09a44 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -19,6 +19,7 @@ import type {
import type { ApiKeyAuthRepository } from "../db/repositories/api-key-repo.js";
import type { SessionLifecycleRepository, SigningRequestsRepository } from "../audit/index.js";
import { registerAgentProxyRoute } from "../agent-socket/index.js";
+import { createDemoEndpointsService } from "../demo-endpoints/index.js";
import { registerMcpHttpTransport } from "../mcp/http-transport.js";
import type { PendingActionStore } from "../pending-action/index.js";
import type { WebSocketChannel } from "../pending-action/index.js";
@@ -168,10 +169,15 @@ export async function buildApp(params: BuildAppParams) {
}
});
+ // Virtual demo-endpoints: synthesized from config.demoEndpoints, merged into
+ // each account's endpoint list when accounts.show_demo_endpoints is true.
+ // Never copied into the endpoints table — config is the source of truth.
+ const demoEndpoints = createDemoEndpointsService(config.demoEndpoints);
+
// --- REST API routes ---
- registerAccountRoutes({ app, accountRepo, db, accountLifecycle });
+ registerAccountRoutes({ app, accountRepo, demoEndpoints, db, accountLifecycle });
registerSshKeyRoutes({ app, keyRepo, accountRepo, keyAvailability });
- registerEndpointRoutes({ app, endpointRepo, accountRepo, terminalManager });
+ registerEndpointRoutes({ app, endpointRepo, accountRepo, demoEndpoints, terminalManager });
const wsHandler = registerWebSocket({ app, terminalManager });
for (const ext of wsExtensions) wsHandler.addExtension(ext);
@@ -180,6 +186,7 @@ export async function buildApp(params: BuildAppParams) {
app,
endpointRepo,
accountRepo,
+ demoEndpoints,
terminalManager,
});
@@ -214,6 +221,7 @@ export async function buildApp(params: BuildAppParams) {
config,
terminalManager,
endpointRepo,
+ demoEndpoints,
keyRepo,
accountRepo,
accountLifecycle,
diff --git a/src/server/routes/accounts.test.ts b/src/server/routes/accounts.test.ts
index 7bd115e..3860cd1 100644
--- a/src/server/routes/accounts.test.ts
+++ b/src/server/routes/accounts.test.ts
@@ -2,6 +2,8 @@
import Fastify from "fastify";
import { describe, expect, it, vi } from "vitest";
import type { AccountInfo, AccountRepository } from "../../db/index.js";
+import type { DemoEndpoint } from "../../config/schema.js";
+import { createDemoEndpointsService } from "../../demo-endpoints/index.js";
import { AccountLifecycle } from "../account-lifecycle.js";
import { registerAccountRoutes } from "./accounts.js";
@@ -16,6 +18,7 @@ function stubAccount(id: string, isAdmin: boolean): AccountInfo {
isAdmin,
enabled: true,
maxSessions: 5,
+ showDemoEndpoints: true,
lastUsedAt: null,
createdAt: now,
updatedAt: now,
@@ -45,7 +48,10 @@ function fakeRepo(opts: { adminId: string }): AccountRepository {
};
}
-async function buildApp(callerAccountId: string) {
+async function buildApp(
+ callerAccountId: string,
+ opts: { demoEndpoints?: readonly DemoEndpoint[] } = {},
+) {
const app = Fastify({ logger: false });
const accountRepo = fakeRepo({ adminId: ADMIN_ID });
const accountLifecycle = new AccountLifecycle();
@@ -53,7 +59,13 @@ async function buildApp(callerAccountId: string) {
app.addHook("onRequest", async (request) => {
request.accountId = callerAccountId;
});
- registerAccountRoutes({ app, accountRepo, accountLifecycle, db: null });
+ registerAccountRoutes({
+ app,
+ accountRepo,
+ demoEndpoints: createDemoEndpointsService(opts.demoEndpoints ?? []),
+ accountLifecycle,
+ db: null,
+ });
return { app, accountLifecycle };
}
@@ -95,3 +107,35 @@ describe("DELETE /api/accounts/:id lifecycle emit", () => {
await app.close();
});
});
+
+describe("GET /api/auth/me — demoEndpointsAvailable", () => {
+ it("returns false when no demoEndpoints are configured", async () => {
+ const { app } = await buildApp(ADMIN_ID, { demoEndpoints: [] });
+
+ const res = await app.inject({ method: "GET", url: "/api/auth/me" });
+
+ expect(res.statusCode).toBe(200);
+ const body = res.json() as { demoEndpointsAvailable: boolean };
+ expect(body.demoEndpointsAvailable).toBe(false);
+ await app.close();
+ });
+
+ it("returns true when at least one demoEndpoint is configured", async () => {
+ const { app } = await buildApp(ADMIN_ID, {
+ demoEndpoints: [
+ {
+ label: "Demo: 2048",
+ address: { host: "ssh.example.com", port: 22, username: "sw-2048" },
+ agentForward: false,
+ },
+ ],
+ });
+
+ const res = await app.inject({ method: "GET", url: "/api/auth/me" });
+
+ expect(res.statusCode).toBe(200);
+ const body = res.json() as { demoEndpointsAvailable: boolean };
+ expect(body.demoEndpointsAvailable).toBe(true);
+ await app.close();
+ });
+});
diff --git a/src/server/routes/accounts.ts b/src/server/routes/accounts.ts
index f137582..44c8c1f 100644
--- a/src/server/routes/accounts.ts
+++ b/src/server/routes/accounts.ts
@@ -10,18 +10,20 @@ import {
endpoints as endpointsTable,
webauthnCredentials,
} from "../../db/schema.js";
+import type { DemoEndpointsService } from "../../demo-endpoints/index.js";
import { formatEndpointAddress } from "../../utils/endpoint-address.js";
import type { AccountLifecycle } from "../account-lifecycle.js";
export interface AccountRoutesParams {
app: FastifyInstance;
accountRepo: AccountRepository;
+ demoEndpoints: DemoEndpointsService;
db?: ShellWatchDB | null;
accountLifecycle: AccountLifecycle;
}
export function registerAccountRoutes(params: AccountRoutesParams) {
- const { app, accountRepo, db = null, accountLifecycle } = params;
+ const { app, accountRepo, demoEndpoints, db = null, accountLifecycle } = params;
// --- Auth: current account ---
app.get("/api/auth/me", async (request, reply) => {
@@ -34,26 +36,48 @@ export function registerAccountRoutes(params: AccountRoutesParams) {
id: account.id,
name: account.name,
isAdmin: account.isAdmin,
+ // showDemoEndpoints is a per-account *visibility* preference, not an
+ // authorization gate. The `demo:*` virtual ids stay resolvable on the
+ // connect path regardless of this flag — operator-curated demo entries
+ // aren't sensitive, the demo container's ForceCommand pinning is the
+ // load-bearing control. Toggling this off only hides demos from
+ // /api/endpoints listings (UI + MCP).
+ showDemoEndpoints: account.showDemoEndpoints,
+ // Whether the *operator* configured any demoEndpoints at all. The UI
+ // hides the entire Demo Endpoints section + toggle when this is false,
+ // so vanilla deployments don't show an inert control.
+ demoEndpointsAvailable: !demoEndpoints.isEmpty(),
};
});
- app.put<{ Body: { name?: string } }>("/api/auth/me", async (request, reply) => {
- const accountId = request.accountId;
- const { name } = request.body;
- const updates: Partial<{ name: string }> = {};
- if (name !== undefined) {
- const trimmed = name.trim();
- if (!trimmed) {
- reply.status(400);
- return { error: "Name cannot be empty" };
+ app.put<{ Body: { name?: string; showDemoEndpoints?: boolean } }>(
+ "/api/auth/me",
+ async (request, reply) => {
+ const accountId = request.accountId;
+ const { name, showDemoEndpoints } = request.body;
+ const updates: Partial<{ name: string; showDemoEndpoints: boolean }> = {};
+ if (name !== undefined) {
+ const trimmed = name.trim();
+ if (!trimmed) {
+ reply.status(400);
+ return { error: "Name cannot be empty" };
+ }
+ updates.name = trimmed;
}
- updates.name = trimmed;
- }
- if (Object.keys(updates).length > 0) {
- await accountRepo.update(accountId, updates);
- }
- return { status: "updated" };
- });
+ if (showDemoEndpoints !== undefined) {
+ if (typeof showDemoEndpoints !== "boolean") {
+ reply.status(400);
+ return { error: "showDemoEndpoints must be a boolean" };
+ }
+ // Visibility flag only; see the GET handler for the auth-gate caveat.
+ updates.showDemoEndpoints = showDemoEndpoints;
+ }
+ if (Object.keys(updates).length > 0) {
+ await accountRepo.update(accountId, updates);
+ }
+ return { status: "updated" };
+ },
+ );
// --- Account Management (admin only) ---
@@ -178,6 +202,12 @@ export function registerAccountRoutes(params: AccountRoutesParams) {
};
});
+ // formatEndpointAddress always emits a `user@` prefix, even when the user
+ // is the default `shellwatch` — surfaces the wire-level identity to the
+ // operator. Round-trips identically through parseEndpointAddress, so an
+ // exported config re-imports cleanly. Side effect worth noting: re-exports
+ // of configs that previously omitted the default user will diff against
+ // their old form (now explicit `shellwatch@…`).
const seedEndpoints = eps.map((ep) => ({
label: ep.label,
address: formatEndpointAddress({
diff --git a/src/server/routes/endpoints.ts b/src/server/routes/endpoints.ts
index 4040883..f820f9b 100644
--- a/src/server/routes/endpoints.ts
+++ b/src/server/routes/endpoints.ts
@@ -8,6 +8,8 @@ import {
USER_VERIFICATION_VALUES,
type UserVerification,
} from "../../db/repositories/endpoint-repo.js";
+import type { DemoEndpointsService } from "../../demo-endpoints/index.js";
+import { isDemoEndpointId } from "../../demo-endpoints/index.js";
import type { TerminalManager } from "../../terminal/index.js";
function normalizeDescription(value: unknown): { ok: true; value: string | null } | { ok: false } {
@@ -22,17 +24,25 @@ export interface EndpointRoutesParams {
app: FastifyInstance;
endpointRepo: EndpointRepository;
accountRepo: AccountRepository;
+ demoEndpoints: DemoEndpointsService;
terminalManager: TerminalManager;
}
export function registerEndpointRoutes(params: EndpointRoutesParams) {
- const { app, endpointRepo, terminalManager } = params;
+ const { app, endpointRepo, accountRepo, demoEndpoints, terminalManager } = params;
app.get("/api/endpoints", async (request) => {
- const all = await endpointRepo.findAllForAccount(request.accountId);
+ const own = await endpointRepo.findAllForAccount(request.accountId);
+ const account = await accountRepo.findById(request.accountId);
+ const merged = [
+ ...own.map((e) => ({ ...e, isDemo: false as const })),
+ ...(account?.showDemoEndpoints
+ ? demoEndpoints.list(request.accountId).map((e) => ({ ...e, isDemo: true as const }))
+ : []),
+ ];
return {
- endpoints: all.map(
- ({ id, label, host, port, username, userVerification, agentForward, description }) => ({
+ endpoints: merged.map(
+ ({
id,
label,
host,
@@ -41,6 +51,17 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) {
userVerification,
agentForward,
description,
+ isDemo,
+ }) => ({
+ id,
+ label,
+ host,
+ port,
+ username,
+ userVerification,
+ agentForward,
+ description,
+ isDemo,
}),
),
};
@@ -112,6 +133,10 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) {
};
}>("/api/endpoints/:id", async (request, reply) => {
try {
+ if (isDemoEndpointId(request.params.id)) {
+ reply.status(400);
+ return { error: "Demo endpoints are read-only — toggle visibility instead" };
+ }
const body = request.body;
if (body.userVerification !== undefined && !isUserVerification(body.userVerification)) {
reply.status(400);
@@ -149,6 +174,10 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) {
app.delete<{ Params: { id: string } }>("/api/endpoints/:id", async (request, reply) => {
try {
+ if (isDemoEndpointId(request.params.id)) {
+ reply.status(400);
+ return { error: "Demo endpoints are read-only — toggle visibility instead" };
+ }
const activeSessions = terminalManager
.listSessions()
.filter((s) => s.endpointId === request.params.id);
diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts
index f4f906b..1572684 100644
--- a/src/server/routes/sessions.ts
+++ b/src/server/routes/sessions.ts
@@ -1,17 +1,20 @@
// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
import type { FastifyInstance } from "fastify";
import type { AccountRepository, EndpointRepository } from "../../db/index.js";
+import type { DemoEndpointsService } from "../../demo-endpoints/index.js";
+import { isDemoEndpointId } from "../../demo-endpoints/index.js";
import type { TerminalManager } from "../../terminal/index.js";
export interface SessionRoutesParams {
app: FastifyInstance;
endpointRepo: EndpointRepository;
accountRepo: AccountRepository;
+ demoEndpoints: DemoEndpointsService;
terminalManager: TerminalManager;
}
export function registerSessionRoutes(params: SessionRoutesParams) {
- const { app, endpointRepo, accountRepo, terminalManager } = params;
+ const { app, endpointRepo, accountRepo, demoEndpoints, terminalManager } = params;
app.post<{ Body: { endpointId: string } }>("/api/sessions", async (request, reply) => {
try {
@@ -30,7 +33,16 @@ export function registerSessionRoutes(params: SessionRoutesParams) {
}
const { endpointId } = request.body;
- const endpoint = await endpointRepo.findByIdForAccount(endpointId, request.accountId);
+ // Demo endpoints are virtual (config-only). Connect deliberately
+ // bypasses the per-account `showDemoEndpoints` toggle — the toggle is a
+ // *visibility* preference, not an authorization gate. Demo entries are
+ // global, operator-curated, and not sensitive; the demo container's
+ // ForceCommand pinning is the real control. Hiding them from the list
+ // shouldn't break a session a caller has already chosen to open (e.g.
+ // when re-using a bookmarked URL with the demo id).
+ const endpoint = isDemoEndpointId(endpointId)
+ ? demoEndpoints.findById(endpointId, request.accountId)
+ : await endpointRepo.findByIdForAccount(endpointId, request.accountId);
if (!endpoint) {
reply.status(404);
return { error: "Endpoint not found" };
diff --git a/src/test/helpers/test-config.ts b/src/test/helpers/test-config.ts
index 1888100..35332f0 100644
--- a/src/test/helpers/test-config.ts
+++ b/src/test/helpers/test-config.ts
@@ -5,6 +5,7 @@ const defaults: Config = {
keyDirectory: "/tmp",
seedAdminEndpoints: [],
seedAdminPasskeys: [],
+ demoEndpoints: [],
server: { ...serverDefaults, externalUrl: "http://localhost:3000" },
security: {
...securityFieldDefaults,
diff --git a/src/test/integration/demo-endpoints-flow.test.ts b/src/test/integration/demo-endpoints-flow.test.ts
new file mode 100644
index 0000000..12b8e66
--- /dev/null
+++ b/src/test/integration/demo-endpoints-flow.test.ts
@@ -0,0 +1,250 @@
+// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
+/**
+ * Integration tests for the virtual demo-endpoint plumbing.
+ *
+ * Covers the REST surface end-to-end:
+ * - GET /api/endpoints merges / excludes demo entries based on the account's
+ * showDemoEndpoints toggle.
+ * - PUT and DELETE /api/endpoints/:id reject `demo:*` virtual ids with 400.
+ * - POST /api/sessions resolves `demo:*` ids regardless of the toggle state
+ * (visibility hides them, but the connect path stays usable).
+ *
+ * Per-test Fastify rig — mirrors passkey-invite-flow.test.ts's approach so the
+ * tests don't drag in the full buildApp() stack for an isolated surface.
+ */
+import Fastify, { type FastifyInstance } from "fastify";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { AccountInfo, AccountRepository } from "../../db/repositories/account-repo.js";
+import { InMemoryEndpointRepository } from "../../db/repositories/endpoint-repo.js";
+import {
+ createDemoEndpointsService,
+ type DemoEndpointsService,
+} from "../../demo-endpoints/index.js";
+import { registerEndpointRoutes } from "../../server/routes/endpoints.js";
+import { registerSessionRoutes } from "../../server/routes/sessions.js";
+import type { TerminalManager } from "../../terminal/index.js";
+
+const ACCOUNT_ID = "acct-test";
+
+function buildAccountRepo(initial: Partial = {}): AccountRepository {
+ const now = new Date().toISOString();
+ const state: AccountInfo = {
+ id: ACCOUNT_ID,
+ name: "Test",
+ isAdmin: false,
+ enabled: true,
+ maxSessions: 5,
+ showDemoEndpoints: true,
+ lastUsedAt: null,
+ createdAt: now,
+ updatedAt: now,
+ ...initial,
+ };
+ return {
+ async findById(id: string) {
+ return id === ACCOUNT_ID ? state : null;
+ },
+ async findAll() {
+ return [state];
+ },
+ async update(_id, data) {
+ Object.assign(state, data);
+ },
+ touchLastUsed() {},
+ flushLastUsed() {},
+ getAdminAccountId() {
+ return null;
+ },
+ setAdmin() {},
+ isAdmin() {
+ return false;
+ },
+ destroy() {},
+ };
+}
+
+function buildTerminalManager(): TerminalManager {
+ // Only the surfaces the routes under test actually touch — listSessions for
+ // the delete-while-active check on real endpoints, and create() for the
+ // session-open path. Everything else throws if reached so a test that
+ // accidentally exercises an unmocked surface fails loudly rather than
+ // hanging on a stub call.
+ const created: { sessionId: string; endpointId: string; accountId: string }[] = [];
+ const manager = {
+ listSessions: vi.fn().mockReturnValue([]),
+ create: vi.fn().mockImplementation(async (endpoint, accountId) => {
+ const sessionId = `sess-${created.length + 1}`;
+ created.push({ sessionId, endpointId: endpoint.id, accountId });
+ return {
+ sessionId,
+ endpointId: endpoint.id,
+ accountId,
+ status: "open",
+ createdAt: new Date(),
+ lastActivityAt: new Date(),
+ source: "ui",
+ };
+ }),
+ } as unknown as TerminalManager;
+ return manager;
+}
+
+interface Rig {
+ app: FastifyInstance;
+ endpointRepo: InMemoryEndpointRepository;
+ demoEndpoints: DemoEndpointsService;
+ accountRepo: AccountRepository;
+ terminalManager: TerminalManager;
+ demoEndpointId: string;
+}
+
+const DEMO_LABEL = "Demo: 2048";
+const DEMO_HOST = "ssh.example.com";
+const DEMO_PORT = 22;
+const DEMO_USER = "sw-2048";
+
+async function buildRig(opts: { showDemoEndpoints?: boolean } = {}): Promise {
+ const accountRepo = buildAccountRepo({
+ showDemoEndpoints: opts.showDemoEndpoints ?? true,
+ });
+ const demoEndpoints = createDemoEndpointsService([
+ {
+ label: DEMO_LABEL,
+ address: { host: DEMO_HOST, port: DEMO_PORT, username: DEMO_USER },
+ agentForward: false,
+ },
+ ]);
+ const endpointRepo = new InMemoryEndpointRepository([
+ {
+ id: "real-ep",
+ accountId: ACCOUNT_ID,
+ label: "Real Server",
+ host: "real.example.com",
+ port: 22,
+ username: "ubuntu",
+ },
+ ]);
+ const terminalManager = buildTerminalManager();
+
+ const app = Fastify({ logger: false });
+ app.decorateRequest("accountId", "");
+ app.addHook("onRequest", async (request) => {
+ request.accountId = ACCOUNT_ID;
+ });
+ registerEndpointRoutes({ app, endpointRepo, accountRepo, demoEndpoints, terminalManager });
+ registerSessionRoutes({ app, endpointRepo, accountRepo, demoEndpoints, terminalManager });
+ await app.ready();
+
+ // Compute the demo id the same way the service does so we don't reach into
+ // its internals to fetch it.
+ const list = demoEndpoints.list(ACCOUNT_ID);
+ const demoEndpointId = list[0].id;
+
+ return { app, endpointRepo, demoEndpoints, accountRepo, terminalManager, demoEndpointId };
+}
+
+describe("GET /api/endpoints — demo merge", () => {
+ let rig: Rig;
+ afterEach(async () => {
+ await rig.app.close();
+ });
+
+ it("includes demo entries when showDemoEndpoints is on", async () => {
+ rig = await buildRig({ showDemoEndpoints: true });
+ const res = await rig.app.inject({ method: "GET", url: "/api/endpoints" });
+ expect(res.statusCode).toBe(200);
+ const body = res.json() as { endpoints: { id: string; isDemo: boolean }[] };
+ const ids = body.endpoints.map((e) => e.id);
+ expect(ids).toContain("real-ep");
+ expect(ids).toContain(rig.demoEndpointId);
+ const demoRow = body.endpoints.find((e) => e.id === rig.demoEndpointId)!;
+ expect(demoRow.isDemo).toBe(true);
+ });
+
+ it("omits demo entries when showDemoEndpoints is off", async () => {
+ rig = await buildRig({ showDemoEndpoints: false });
+ const res = await rig.app.inject({ method: "GET", url: "/api/endpoints" });
+ expect(res.statusCode).toBe(200);
+ const body = res.json() as { endpoints: { id: string }[] };
+ const ids = body.endpoints.map((e) => e.id);
+ expect(ids).toContain("real-ep");
+ expect(ids.some((id) => id.startsWith("demo:"))).toBe(false);
+ });
+});
+
+describe("mutation rejection on demo: ids", () => {
+ let rig: Rig;
+ beforeEach(async () => {
+ rig = await buildRig();
+ });
+ afterEach(async () => {
+ await rig.app.close();
+ });
+
+ it("PUT /api/endpoints/:id refuses a demo: id", async () => {
+ const res = await rig.app.inject({
+ method: "PUT",
+ url: `/api/endpoints/${encodeURIComponent(rig.demoEndpointId)}`,
+ payload: { label: "Should not stick" },
+ });
+ expect(res.statusCode).toBe(400);
+ const body = res.json() as { error: string };
+ expect(body.error).toMatch(/read-only/i);
+ });
+
+ it("DELETE /api/endpoints/:id refuses a demo: id", async () => {
+ const res = await rig.app.inject({
+ method: "DELETE",
+ url: `/api/endpoints/${encodeURIComponent(rig.demoEndpointId)}`,
+ });
+ expect(res.statusCode).toBe(400);
+ const body = res.json() as { error: string };
+ expect(body.error).toMatch(/read-only/i);
+ });
+});
+
+describe("POST /api/sessions — demo endpoint opens regardless of toggle", () => {
+ let rig: Rig;
+ afterEach(async () => {
+ await rig.app.close();
+ });
+
+ it("opens a session against a demo: id when the toggle is on", async () => {
+ rig = await buildRig({ showDemoEndpoints: true });
+ const res = await rig.app.inject({
+ method: "POST",
+ url: "/api/sessions",
+ payload: { endpointId: rig.demoEndpointId },
+ });
+ expect(res.statusCode).toBe(200);
+ const create = rig.terminalManager.create as unknown as ReturnType;
+ expect(create).toHaveBeenCalledTimes(1);
+ const passed = create.mock.calls[0][0];
+ expect(passed.host).toBe(DEMO_HOST);
+ expect(passed.username).toBe(DEMO_USER);
+ });
+
+ it("opens a session against a demo: id even when the toggle is off (visibility-only)", async () => {
+ rig = await buildRig({ showDemoEndpoints: false });
+ const res = await rig.app.inject({
+ method: "POST",
+ url: "/api/sessions",
+ payload: { endpointId: rig.demoEndpointId },
+ });
+ expect(res.statusCode).toBe(200);
+ const create = rig.terminalManager.create as unknown as ReturnType;
+ expect(create).toHaveBeenCalledTimes(1);
+ const passed = create.mock.calls[0][0];
+ expect(passed.host).toBe(DEMO_HOST);
+ });
+
+ it("404s for an unknown demo id", async () => {
+ rig = await buildRig({ showDemoEndpoints: true });
+ const res = await rig.app.inject({
+ method: "POST",
+ url: "/api/sessions",
+ payload: { endpointId: "demo:doesnotexist" },
+ });
+ expect(res.statusCode).toBe(404);
+ });
+});
diff --git a/src/utils/endpoint-address.test.ts b/src/utils/endpoint-address.test.ts
index 58df346..97a5495 100644
--- a/src/utils/endpoint-address.test.ts
+++ b/src/utils/endpoint-address.test.ts
@@ -77,25 +77,25 @@ describe("parseEndpointAddress", () => {
});
describe("formatEndpointAddress", () => {
- it("omits defaults", () => {
+ it("always renders the username, even when it's the default", () => {
expect(formatEndpointAddress({ username: "shellwatch", host: "example.com", port: 22 })).toBe(
- "example.com",
+ "shellwatch@example.com",
);
});
- it("includes non-default username", () => {
+ it("renders a non-default username", () => {
expect(formatEndpointAddress({ username: "deploy", host: "example.com", port: 22 })).toBe(
"deploy@example.com",
);
});
- it("includes non-default port", () => {
+ it("omits the default port (22)", () => {
expect(formatEndpointAddress({ username: "shellwatch", host: "example.com", port: 2222 })).toBe(
- "example.com:2222",
+ "shellwatch@example.com:2222",
);
});
- it("includes both non-defaults", () => {
+ it("renders user + non-default port", () => {
expect(
formatEndpointAddress({ username: "deploy", host: "dev.example.com", port: 62222 }),
).toBe("deploy@dev.example.com:62222");
diff --git a/src/utils/endpoint-address.ts b/src/utils/endpoint-address.ts
index 4c3f8ef..3cc3002 100644
--- a/src/utils/endpoint-address.ts
+++ b/src/utils/endpoint-address.ts
@@ -82,12 +82,13 @@ function parsePort(portStr: string, original: string): number {
}
/**
- * Format an endpoint address, omitting defaults.
- * - username omitted if "shellwatch"
- * - port omitted if 22
+ * Format an endpoint address. The username is always rendered so the display
+ * is unambiguous when the endpoint was created without a `user@` prefix and
+ * picked up the `shellwatch` default — surfacing the default explicitly
+ * matches what sshd actually sees on the wire. The port is still omitted
+ * when it's the SSH default (22).
*/
export function formatEndpointAddress(ep: EndpointAddress): string {
- const userPart = ep.username !== DEFAULT_USERNAME ? `${ep.username}@` : "";
const portPart = ep.port !== DEFAULT_PORT ? `:${ep.port}` : "";
- return `${userPart}${ep.host}${portPart}`;
+ return `${ep.username}@${ep.host}${portPart}`;
}