feat: add bounded admin WebSocket backend (Phase 8D.5)

- Require current admin cookie sessions, Origin checks and single-use
  tickets
- Reuse the shared console with session-aware authorization and slot
  allocation
- Add HTTPD-owned I/O, bounded buffering and revocation cleanup
- Prevent LRU eviction of serial clients and stale admin socket closure
- Reject unsupported web-shell mutations before side effects
- Add host regressions, a smoke client and resource accounting

Validated by user sign-off after a 15-minute full-client soak at 230400
baud, with a few broker drops under heavy output. Browser UI remains
for Phase 8D.6; numeric memory reserves remain open.
This commit is contained in:
2026-09-06 14:41:41 +02:00
parent e5dce12ed4
commit aeb2043396
37 changed files with 3651 additions and 91 deletions
+84 -4
View File
@@ -1,29 +1,109 @@
#define SSH_TRANSPORT_MAX_SESSIONS 2U
typedef struct { bool active, tx_pending; uint32_t session_id, generation; } ssh_transport_session_snapshot_t;
enum { SSH_TRANSPORT_SESSION_FREE=0, SSH_TRANSPORT_SESSION_ACTIVE=2,
SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE=2 };
enum { USER_AUTH_METHOD_PASSWORD=0 };
typedef struct {
uint32_t session_id, generation, broker_client_id;
int state, route, socket_fd;
uint8_t console_slot_index;
bool authenticated, principal_valid, writer, close_requested;
size_t rx_length, rx_offset, tx_length, tx_offset;
user_principal_t principal;
char peer[48];
} ssh_slot_t;
typedef struct {
bool active, tx_pending, rx_pending, authenticated, principal_valid, close_requested;
bool writer, admin_command_pending;
uint32_t session_id, generation, broker_client_id, admin_output_pending;
int state, route, socket_fd, user_role, auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY+1U], peer[48];
} ssh_transport_session_snapshot_t;
static ssh_transport_session_snapshot_t s_session_snapshots[2];
static user_principal_t s_console_principals[2];
static uint8_t s_console_slot_indices[2];
static uint32_t s_external_close_id[2];
static unsigned stopped, disconnected, rotated, reset, restarted;
static esp_err_t ssh_transport_stop(void) { ++stopped; return ESP_OK; }
static esp_err_t ssh_transport_disconnect(uint32_t id) { disconnected=id; return ESP_OK; }
static esp_err_t ssh_transport_replace_host_key(bool r) { if(r) ++reset; else ++rotated; return ESP_OK; }
static void esp_restart(void) { ++restarted; }
static void publish_slot(const ssh_slot_t *, size_t);
static bool admin_console_drained(const admin_ssh_console_token_t *);
static bool admin_console_is_current(const admin_ssh_console_token_t *, const user_principal_t *);
static bool consume_external_close(const ssh_slot_t *, size_t);
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *, admin_ssh_deferred_action_type_t, uint32_t);
static void test_adapter(void)
{
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
user_principal_t admin={ .role=USER_ROLE_ADMIN };
user_principal_t admin={ .role=USER_ROLE_ADMIN, .user_id=11, .auth_generation=2,
.username_length=5, .username="admin" };
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
assert(!admin_console_drained(&token));
s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 };
assert(!admin_console_drained(&token)); /* No published console binding. */
assert(!admin_console_is_current(&token,&admin));
ssh_slot_t active={ .session_id=7, .generation=3, .authenticated=true,
.principal_valid=true, .state=SSH_TRANSPORT_SESSION_ACTIVE,
.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE, .principal=admin };
publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
/* Production publication carries both console output and principal binding. */
assert(s_session_snapshots[0].tx_pending && s_session_snapshots[0].admin_output_pending);
assert(!strcmp(s_session_snapshots[0].username,"admin"));
uint8_t output[4096]; size_t n;
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
publish_slot(&active,0);
assert(admin_console_drained(&token));
active.state=SSH_TRANSPORT_SESSION_FREE; active.principal_valid=false;
publish_slot(&active,0);
user_principal_t empty={0};
assert(!memcmp(&s_console_principals[0],&empty,sizeof(empty)));
assert(!admin_console_is_current(&token,&admin));
active.state=SSH_TRANSPORT_SESSION_ACTIVE; active.principal_valid=true;
publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
admin.username[0]='A'; assert(!admin_console_is_current(&token,&admin)); admin.username[0]='a';
active.route=0; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
active.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
active.authenticated=false; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
active.authenticated=true; publish_slot(&active,0);
s_external_close_id[0]=7; assert(!admin_console_is_current(&token,&admin));
ssh_slot_t closing={.session_id=7, .state=SSH_TRANSPORT_SESSION_ACTIVE};
assert(consume_external_close(&closing,0) && !s_external_close_id[0]);
assert(!admin_console_is_current(&token,&admin));
s_session_snapshots[0].close_requested=true;
assert(!admin_console_is_current(&token,&admin)); s_session_snapshots[0].close_requested=false;
++admin.auth_generation; assert(!admin_console_is_current(&token,&admin)); --admin.auth_generation;
++admin.user_id; assert(!admin_console_is_current(&token,&admin)); --admin.user_id;
++admin.method; assert(!admin_console_is_current(&token,&admin)); --admin.method;
admin.username_length=1; assert(!admin_console_is_current(&token,&admin)); admin.username_length=5;
token.transport=1; assert(!admin_console_drained(&token));
assert(!admin_console_is_current(&token,&admin));
assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG);
token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token));
assert(!admin_console_is_current(&token,&admin));
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0);
token.slot_generation=3; s_session_snapshots[0].tx_pending=true;
token.slot_generation=3;
/* Same physical session can be assigned the other console slot. */
admin_ssh_console_close(&token);
token.slot_index=1;
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
active.console_slot_index=1; publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
assert(s_console_slot_indices[0]==1);
admin_ssh_console_token_t wrong=token; wrong.slot_index=0;
assert(!admin_console_is_current(&wrong,&admin) && !admin_console_drained(&wrong));
assert(admin_console_perform(&wrong,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND);
/* A colliding published ID with a stale generation cannot steal the lookup. */
s_session_snapshots[1]=s_session_snapshots[0];
++s_session_snapshots[1].generation; s_console_slot_indices[1]=0;
assert(admin_console_is_current(&token,&admin));
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
publish_slot(&active,0);
s_session_snapshots[0].tx_pending=true;
assert(!admin_console_drained(&token)); s_session_snapshots[0].tx_pending=false;
assert(admin_console_perform(&token,ADMIN_CONSOLE_DEFER_SELF_CLOSE,99)==ESP_OK && disconnected==7);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_DISCONNECT,99)==ESP_OK && disconnected==99);
@@ -31,5 +111,5 @@ static void test_adapter(void)
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_ROTATE,0)==ESP_OK && rotated==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_RESET,0)==ESP_OK && reset==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1);
puts("PASS: actual SSH adapter identity/drain checks, legacy admission and lifecycle action routing");
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, legacy admission and lifecycle action routing");
}
+11 -5
View File
@@ -10,7 +10,13 @@ typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND };
enum { USER_ROLE_USER, USER_ROLE_ADMIN };
typedef struct { int role; } user_principal_t;
#define USER_DATABASE_USERNAME_CAPACITY 16U
typedef struct {
uint32_t user_id, auth_generation;
int role, method;
size_t username_length;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
} user_principal_t;
typedef unsigned TickType_t;
typedef void *TaskHandle_t;
typedef int portMUX_TYPE;
@@ -38,7 +44,7 @@ static size_t strlcpy(char *d, const char *s, size_t n) {
memcpy(d, s, k); d[k] = 0; } return len;
}
static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *c)
{ (void)p; *c = principal_current; return ESP_OK; }
{ (void)p; assert(!lock_depth); *c = principal_current; return ESP_OK; }
static const char *esp_err_to_name(int e) { (void)e; return "fake"; }
static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; }
static unsigned xTaskGetTickCount(void) { return ticks; }
@@ -57,7 +63,8 @@ static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size); q->count=0; return 1; }
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
{ if (t && prompt_hook) prompt_hook(); int r=*s; *s=0; return r; }
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
int r=*s; *s=0; return r; }
static int xSemaphoreGive(SemaphoreHandle_t s) { *s=1; return 1; }
static void linenoiseSetMaxLineLen(unsigned n) { (void)n; }
static char *linenoise(const char *p) { (void)p; return NULL; }
@@ -67,8 +74,7 @@ static bool console_completion_expand(const char *s, char *d, size_t n)
{ (void)s; (void)d; (void)n; if (completion_hook) completion_hook(); return false; }
static bool console_completion_format_matches(const char *s, char *d, size_t n, size_t *len)
{ (void)s; *len=strlcpy(d,"help\r\n",n); return true; }
static size_t esp_console_split_argv(char *s, char **v, size_t n)
{ (void)s; (void)v; (void)n; return 0; } /* Real parser tested by admin_ssh_policy. */
size_t esp_console_split_argv(char *s, char **v, size_t n);
static esp_err_t esp_console_run(const char *s, int *r)
{ (void)s; ++runs; if (command_hook) command_hook(); *r=0; return ESP_OK; }
typedef struct { const char *command, *help, *hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
+7 -4
View File
@@ -4,10 +4,13 @@
No target scheduler, socket library, or hardware execution is claimed.
"""
from pathlib import Path
import os
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
parser = str(IDF / "components/console/split_argv.c")
source = (ROOT / "src/admin_ssh_console.c").read_text()
header = (ROOT / "src/admin_ssh_console.h").read_text()
def strip_includes(text):
@@ -21,18 +24,18 @@ with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
+ (ROOT / "tests/admin_console_boundary/test.c").read_text())
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-g", str(path / "test.c"),
"-g", str(path / "test.c"), parser,
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
ssh = (ROOT / "src/ssh_transport.c").read_text()
adapter = ssh[ssh.index("static bool admin_console_drained("):
ssh.index("static bool consume_external_close(")]
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
ssh.index("static void *ssh_malloc(")]
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/adapter.c").read_text()
+ adapter + "\nint main(void) { test_adapter(); }\n")
(path / "adapter.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-variable", str(path / "adapter.c"),
"-Wno-unused-variable", str(path / "adapter.c"), parser,
"-o", str(path / "adapter")], check=True, timeout=30)
subprocess.run([str(path / "adapter")], check=True, timeout=10)
+141 -1
View File
@@ -2,6 +2,14 @@
static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 };
static admin_ssh_console_token_t b = { .slot_index=1, .session_id=7, .slot_generation=1, .transport=1 };
static user_principal_t admin = { .role=USER_ROLE_ADMIN };
static bool live[2] = {true, true};
static void (*current_hook)(void);
static bool is_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
{
assert(!lock_depth && p->role==USER_ROLE_ADMIN);
if (current_hook) current_hook();
return live[t->slot_index];
}
static bool drained(const admin_ssh_console_token_t *t)
{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; }
static esp_err_t perform(const admin_ssh_console_token_t *t,
@@ -9,6 +17,7 @@ static esp_err_t perform(const admin_ssh_console_token_t *t,
{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; }
static const admin_console_owner_t owner = {
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform,
.is_current=is_current,
};
static void pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); }
static void feed(const admin_ssh_console_token_t *t, const char *s)
@@ -40,12 +49,141 @@ static void close_during_command(void)
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
}
static void setup_dispatch(void)
{ s_dispatch_remote=true; s_dispatch_token=a; s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
{ s_dispatch_remote=true; s_dispatch_token=a; s_dispatch_principal=admin;
s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
static void reopen_during_current(void)
{
current_hook=NULL;
admin_ssh_console_close(&a);
++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
live[0]=false; /* Failed old validation must not close the replacement. */
}
static void revoked_reply(void) { hidden_reply(); live[0]=false; }
static unsigned checks;
static void stale_at_execution(void) { if (++checks==2) live[0]=false; }
static void account_revoked_reply(void) { hidden_reply(); principal_current=false; }
static void closed_reply(void) { hidden_reply(); close_prompt(); }
static unsigned waits;
static void unanswered(void)
{
++waits;
if (waits==1) xSemaphoreGive(s_prompt_done); /* Stale wake while still waiting. */
if (waits==3) live[0]=false; /* No close notification. */
}
static void prompt_command(void)
{
uint8_t answer[32]; size_t n=99;
assert(admin_ssh_console_dispatch_read_input("Password: ",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
assert(n==0);
for (size_t i=0;i<sizeof(answer);++i) assert(!answer[i]);
assert(!s_sessions[0].active && !s_sessions[0].prompt_length);
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i) assert(!s_sessions[0].prompt_input[i]);
}
static void test_currentness(void)
{
++a.slot_generation;
admin_console_owner_t missing=owner; missing.is_current=NULL;
assert(admin_ssh_console_open_owned(&a,&admin,&missing)==ESP_ERR_INVALID_ARG);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
unsigned before=runs;
feed(&a,"owner stale\r"); live[0]=false; pump(worker_task);
assert(runs==before && !s_sessions[0].active && principal_current);
feed(&b,"isolated\r"); pump(worker_task); assert(runs==++before);
live[0]=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
feed(&a,"reuse\r"); current_hook=reopen_during_current; pump(worker_task);
assert(runs==before && token_matches(&s_sessions[0],&a));
live[0]=true;
feed(&a,"last check\r"); checks=0; current_hook=stale_at_execution;
pump(worker_task); current_hook=NULL;
assert(checks==2 && runs==before && !s_sessions[0].active);
live[0]=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
void (*hooks[])(void)={revoked_reply,account_revoked_reply,closed_reply,unanswered};
for (size_t i=0;i<sizeof(hooks)/sizeof(hooks[0]);++i) {
clear_output(&a); ticks=0; waits=0;
feed(&a,"prompt\r"); prompt_hook=hooks[i]; command_hook=prompt_command;
pump(worker_task); prompt_hook=NULL; command_hook=NULL;
assert(runs==++before);
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
if (i==3) assert(waits==3 && ticks==750);
/* Dispatcher recovered, so trusted UART0 work still runs. */
admin_request_t uart={.origin=ADMIN_REQUEST_UART0};
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==++before);
live[0]=true; principal_current=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
}
setup_dispatch(); clear_output(&a); live[0]=false;
uint8_t answer[32]; size_t n=99;
assert(admin_ssh_console_dispatch_read_input("Not published",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
assert(!n && !s_sessions[0].output_length);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); live[0]=true;
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
clear_output(&a);
s_sessions[0].output_start=4094;
assert(worker_write(&a,"abcdef"));
uint8_t out[8];
assert(admin_ssh_console_read_output(&a,out,3,&n)==ESP_OK && n==3 && !memcmp(out,"abc",3));
assert(!s_sessions[0].output[4094] && !s_sessions[0].output[4095] && !s_sessions[0].output[0]);
assert(!memcmp(s_sessions[0].output+1,"def",3));
assert(admin_ssh_console_read_output(&a,out,sizeof(out),&n)==ESP_OK && n==3 && !memcmp(out,"def",3));
for (size_t i=0;i<sizeof(s_sessions[0].output);++i) assert(!s_sessions[0].output[i]);
admin_ssh_console_close(&a);
puts("PASS: owner stale/account current isolation, callback close/reuse, revoked submitted prompts, periodic unanswered invalidation/stale wake, UART recovery, consumed output wiping");
}
static void test_shared_admission(void)
{
admin_ssh_console_token_t web={.slot_index=255, .session_id=7,
.slot_generation=42, .transport=ADMIN_CONSOLE_TRANSPORT_WEB};
admin_ssh_console_token_t ssh=web; ssh.transport=ADMIN_CONSOLE_TRANSPORT_SSH;
static const admin_console_owner_t second_owner={
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
.drained=drained, .perform=perform, .is_current=is_current,
};
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
assert(web.slot_index==0 && web.session_id==7 && web.slot_generation==42 &&
web.transport==ADMIN_CONSOLE_TRANSPORT_WEB);
assert(admin_ssh_console_open_available(&ssh,&admin,&second_owner)==ESP_OK);
assert(ssh.slot_index==1 && ssh.session_id==7 && ssh.slot_generation==42 && !ssh.transport);
assert(s_sessions[0].owner==&owner && s_sessions[1].owner==&second_owner);
unsigned before=runs;
clear_output(&web);
feed(&web,"\"web\" \"stop\"\r"); pump(worker_task);
assert(runs==before && !s_control_queue->count);
uint8_t diagnostic[512]={0}; size_t received=0;
assert(admin_ssh_console_read_output(&web,diagnostic,sizeof(diagnostic)-1,&received)==ESP_OK);
assert(strstr((char *)diagnostic,"unavailable from the web console"));
feed(&web,"\"user\" \"password\" admin --generate\r"); pump(worker_task);
assert(runs==before && !s_control_queue->count);
feed(&web,"\"web\" \"status\"\r"); pump(worker_task); assert(runs==before+1);
/* UART0 bypasses remote policy and remains the recovery path. */
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="user recover --force"};
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==before+2);
runs=before;
admin_ssh_console_token_t full=web; full.slot_index=99;
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(full.slot_index==99);
admin_ssh_console_token_t stale=web;
s_sessions[0].executing=true;
admin_ssh_console_close(&web);
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(full.slot_index==99); /* Inactive executing slots still consume capacity. */
s_sessions[0].executing=false;
++web.slot_generation;
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
admin_ssh_console_close(&stale);
assert(!admin_ssh_console_accepts_input(&stale) && admin_ssh_console_accepts_input(&web));
assert(admin_ssh_console_accepts_input(&ssh));
admin_ssh_console_close(&web); admin_ssh_console_close(&ssh);
puts("PASS: two-owner shared admission, colliding preferred indices/IDs, full capacity, executing reservation and stale tokens");
}
int main(void)
{
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
test_shared_admission();
principal_current=false;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
principal_current=true;
@@ -74,6 +212,7 @@ int main(void)
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
pump(worker_task); assert(runs==1);
feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true;
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 };
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2);
feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL;
@@ -128,5 +267,6 @@ int main(void)
admin_ssh_console_close(&a);
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
assert(!lock_depth);
test_currentness();
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}