From 30fd6829a8088ba94f9126d2b9c0846552d32c83 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:33:45 +0800 Subject: [PATCH] Agent: add zmodem_send for unattended PC->device upload Adds a `zmodem_send` agent method so a client can push a local file to the device over the session Tera Term already holds open -- no file-picker, no DDE, no window relaunch. This is the reverse of the already-working built-in auto-receive (device->PC), and it closes the one gap in hands-off ZMODEM: the built-in auto-*send* path stops at a GUI file-picker (ZMODEMStartSend(NULL,...) -> _GetMultiFname), and ttpmacro cannot attach to an interactively-launched window (it registers no DDE server without /D=), so neither existing route is unattended. Because agent requests are dispatched on the GUI thread (the hidden A.wnd), be_zmodem_send calls ZMODEMStartSend inline -- no cross-thread marshaling. The transfer runs async on the protocol pump; its outcome is surfaced through a new `transfer` object in `status` (a tagged union: state idle|active|done, with `ok` only when done), fed by a hook at the ProtoEnd completion funnel. The client arms the device receiver (`rz`), calls zmodem_send, then polls status.transfer until done. The method reuses the existing send-armed gate, so it grants no capability beyond send_bytes: it reads a local file on the Windows box and streams it out the serial line, both already within the arm's scope. Scope: local (focused) window only. A foreign window's transfer would be neither driveable nor observable from this process (status reads this window's cv_ProtoFlag), so cross-window ZMODEM is deferred and returns NOTALLOWED; send_bytes/text still hand off cross-window via the shm queue. Host-tested (ASan/UBSan): the JSON dispatch/parse/gate (test_zmodem_send), the transfer tagged-union surfacing (test_status_transfer), and MCP tools/list discovery (test_agent_mcp). The Windows-only halves -- be_zmodem_send in agent_server.cpp and the ProtoEnd hook in filesys_proto.cpp -- are not built by the host suite; they need the clang-cl+xwin product build to compile and a hardware run to verify behavior. Claude-Session: https://claude.ai/code/session_01YEkLFtTYQZuQHgQP5qYtq7 --- teraterm/teraterm/agent_jsonrpc.c | 38 +++++ teraterm/teraterm/agent_jsonrpc.h | 9 ++ teraterm/teraterm/agent_mcp.c | 6 + teraterm/teraterm/agent_server.cpp | 43 ++++++ teraterm/teraterm/filesys.h | 1 + teraterm/teraterm/filesys_proto.cpp | 16 ++- teraterm/teraterm/tests/test_agent_jsonrpc.c | 140 +++++++++++++++++++ teraterm/teraterm/tests/test_agent_mcp.c | 6 +- 8 files changed, 255 insertions(+), 4 deletions(-) diff --git a/teraterm/teraterm/agent_jsonrpc.c b/teraterm/teraterm/agent_jsonrpc.c index 388f4decf..d7de245b0 100644 --- a/teraterm/teraterm/agent_jsonrpc.c +++ b/teraterm/teraterm/agent_jsonrpc.c @@ -167,6 +167,8 @@ static const char *agent_err_msg(int rc) return "not allowed"; case AGENT_ERR_NOSESSION: return "unknown session"; + case AGENT_ERR_BUSY: + return "transfer in progress"; default: return rc < 0 ? "operation failed" : NULL; } @@ -221,6 +223,18 @@ static cJSON *call_status(const AgentBackend *be, const cJSON *params, const cha cJSON_AddNumberToObject(r, "offset", (double)st.offset); cJSON_AddNumberToObject(r, "cols", st.cols); cJSON_AddNumberToObject(r, "rows", st.rows); + + /* Transfer state as a tagged union: "ok" is meaningful only once done. */ + cJSON *t = cJSON_CreateObject(); + if (st.xfer_active) { + cJSON_AddStringToObject(t, "state", "active"); + } else if (st.xfer_last_result < 0) { + cJSON_AddStringToObject(t, "state", "idle"); + } else { + cJSON_AddStringToObject(t, "state", "done"); + cJSON_AddBoolToObject(t, "ok", st.xfer_last_result == 1); + } + cJSON_AddItemToObject(r, "transfer", t); return r; } @@ -381,6 +395,28 @@ static cJSON *call_send_key(const AgentBackend *be, const cJSON *params, const c return r; } +static cJSON *call_zmodem_send(const AgentBackend *be, const cJSON *params, const char **err) +{ + const cJSON *pj = cJSON_GetObjectItem(params, "path"); + if (!cJSON_IsString(pj) || pj->valuestring[0] == 0) { + *err = "path required"; + return NULL; + } + int binary = 1; + const cJSON *bj = cJSON_GetObjectItem(params, "binary"); + if (cJSON_IsBool(bj)) + binary = cJSON_IsTrue(bj) ? 1 : 0; + + int rc = be->zmodem_send(be->ctx, param_session(params), pj->valuestring, binary); + *err = agent_err_msg(rc); + if (*err != NULL) + return NULL; + + cJSON *r = cJSON_CreateObject(); + cJSON_AddTrueToObject(r, "started"); + return r; +} + cJSON *agent_call(const AgentBackend *be, const char *method, const cJSON *params, const char **err) { *err = NULL; @@ -398,6 +434,8 @@ cJSON *agent_call(const AgentBackend *be, const char *method, const cJSON *param return call_send_bytes(be, params, err); if (strcmp(method, "send_key") == 0) return call_send_key(be, params, err); + if (strcmp(method, "zmodem_send") == 0) + return call_zmodem_send(be, params, err); *err = "unknown method"; return NULL; } diff --git a/teraterm/teraterm/agent_jsonrpc.h b/teraterm/teraterm/agent_jsonrpc.h index 1703aaa5a..9c29ab59d 100644 --- a/teraterm/teraterm/agent_jsonrpc.h +++ b/teraterm/teraterm/agent_jsonrpc.h @@ -27,6 +27,7 @@ extern "C" { #define AGENT_ERR_NOTCONN (-1) /* no live connection (cv null / !Ready) */ #define AGENT_ERR_NOTALLOWED (-2) /* send not armed for this session */ #define AGENT_ERR_NOSESSION (-3) /* unknown session id */ +#define AGENT_ERR_BUSY (-4) /* a file transfer is already in flight */ typedef struct { int ready; @@ -34,6 +35,8 @@ typedef struct { uint64_t offset; /* ring total; next unread offset */ int cols, rows; char host[256]; + int xfer_active; /* non-zero while a file transfer is in flight */ + int xfer_last_result; /* -1 none, 0 failed, 1 succeeded (valid when !active) */ } AgentStatus; typedef struct { @@ -75,6 +78,12 @@ typedef struct { /* Send raw bytes. Returns bytes accepted or a negative AGENT_ERR_*. */ int (*send_bytes)(void *ctx, const char *session, const void *data, size_t len); + + /* Start a ZMODEM send of the local file at pathU8 over the session's line + * (binary != 0 selects binary mode). Async: returns 0 once the transfer is + * started, or a negative AGENT_ERR_* (NOTALLOWED if send is not armed, + * BUSY if a transfer is already in flight). */ + int (*zmodem_send)(void *ctx, const char *session, const char *pathU8, int binary); } AgentBackend; /* Per-connection state threaded across requests on one socket. */ diff --git a/teraterm/teraterm/agent_mcp.c b/teraterm/teraterm/agent_mcp.c index d5464adad..7a07f6edc 100644 --- a/teraterm/teraterm/agent_mcp.c +++ b/teraterm/teraterm/agent_mcp.c @@ -57,6 +57,12 @@ static const McpTool TOOLS[] = { {"send_key", "Send a named key (enter, tab, esc, ctrl-c, up, down, left, right, ...).", "{\"type\":\"object\",\"properties\":{\"session\":{\"type\":\"string\"}," "\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"}, + {"zmodem_send", + "Start a ZMODEM send of a local file over the connection (the peer must be " + "running a ZMODEM receiver, e.g. 'rz'). Async: returns once started; poll " + "status.transfer for state (active -> done) and ok.", + "{\"type\":\"object\",\"properties\":{\"session\":{\"type\":\"string\"}," + "\"path\":{\"type\":\"string\"},\"binary\":{\"type\":\"boolean\"}},\"required\":[\"path\"]}"}, }; static cJSON *build_tools_array(void) diff --git a/teraterm/teraterm/agent_server.cpp b/teraterm/teraterm/agent_server.cpp index e7cbb7418..5379fab07 100644 --- a/teraterm/teraterm/agent_server.cpp +++ b/teraterm/teraterm/agent_server.cpp @@ -16,6 +16,8 @@ #include "tttypes.h" #include "ttwinman.h" /* cv, ts, HVTWin */ #include "ttcommon.h" /* CommBinaryOut, CommTextOutW */ +#include "codeconv.h" /* ToWcharU8 */ +#include "filesys.h" /* ZMODEMStartSend, ProtoGetProtoFlag, ProtoGetLastResult */ #include "agent_shmem.h" #include "agent_jsonrpc.h" @@ -177,6 +179,11 @@ static int be_get_status(void *ctx, const char *session, AgentStatus *out) if (slot == A.slot) { out->cols = ts.TerminalWidth; out->rows = ts.TerminalHeight; + out->xfer_active = ProtoGetProtoFlag() ? 1 : 0; + out->xfer_last_result = ProtoGetLastResult(); + } else { + /* Another window's transfer state is not visible from this process. */ + out->xfer_last_result = -1; } return 0; } @@ -268,6 +275,41 @@ static int be_send_bytes(void *ctx, const char *session, const void *data, size_ return be_send_common(session, AGENT_CMD_BINARY, data, len); } +static int be_zmodem_send(void *ctx, const char *session, const char *pathU8, int binary) +{ + (void)ctx; + int slot = resolve_slot(session); + if (slot < 0) { + return AGENT_ERR_NOSESSION; + } + AgentSession *s = &A.shm->sessions[slot]; + if (!s->ready) { + return AGENT_ERR_NOTCONN; + } + if (!s->send_armed) { + return AGENT_ERR_NOTALLOWED; + } + /* Local window only. ZMODEMStartSend arms the protocol pump on this GUI + * thread against this process's cv/FileVar, and the transfer's outcome is + * observable only through this window's own status. A foreign window's + * transfer would be neither driven nor visible here, so cross-window ZMODEM + * is deferred (send_bytes/text still hand off via the shm queue). */ + if (slot != A.slot) { + return AGENT_ERR_NOTALLOWED; + } + wchar_t *w = ToWcharU8(pathU8); + if (w == NULL) { + return AGENT_ERR_BUSY; + } + /* FALSE start => a transfer is already in flight (FileVar != NULL) or a + * resource failure; both surface to the caller as "busy". A bad path is + * NOT caught here -- the send starts, then fails via ProtoEnd, which the + * caller observes as transfer.state=done, ok=false. */ + BOOL ok = ZMODEMStartSend(w, binary ? 1 : 0, FALSE); + free(w); + return ok ? 0 : AGENT_ERR_BUSY; +} + static void init_backend(void) { memset(&A.backend, 0, sizeof(A.backend)); @@ -280,6 +322,7 @@ static void init_backend(void) A.backend.read_scrollback = be_read_scrollback; A.backend.send_text = be_send_text; A.backend.send_bytes = be_send_bytes; + A.backend.zmodem_send = be_zmodem_send; } /* ---- client bookkeeping ---- */ diff --git a/teraterm/teraterm/filesys.h b/teraterm/teraterm/filesys.h index f8933ba00..5115d6d95 100644 --- a/teraterm/teraterm/filesys.h +++ b/teraterm/teraterm/filesys.h @@ -44,6 +44,7 @@ void FileSendPause(BOOL Pause); // filesys_proto.cpp BOOL ProtoGetProtoFlag(void); +int ProtoGetLastResult(void); /* -1 none, 0 failed, 1 succeeded */ BOOL IsFileVarNULL(void); void ProtoEnd(void); int ProtoDlgParse(void); diff --git a/teraterm/teraterm/filesys_proto.cpp b/teraterm/teraterm/filesys_proto.cpp index a7d84ee95..64b42685b 100644 --- a/teraterm/teraterm/filesys_proto.cpp +++ b/teraterm/teraterm/filesys_proto.cpp @@ -91,6 +91,9 @@ typedef enum { static PFileVarProto FileVar = NULL; static PProtoDlg PtDlg = NULL; static BOOL cv_ProtoFlag = FALSE; +/* Outcome of the most recent transfer for out-of-band observers (the agent): + * -1 none yet, 0 failed/cancelled, 1 succeeded. Set at the ProtoEnd funnel. */ +static int cv_ProtoLastResult = -1; static void _SetDlgTime(TFileVarProto *fv, DWORD elapsed, int bytes) { @@ -438,14 +441,23 @@ void ProtoEnd(void) CloseProtoDlg(); - if ((FileVar!=NULL) && FileVar->Success) + if ((FileVar!=NULL) && FileVar->Success) { + cv_ProtoLastResult = 1; EndDdeCmnd(1); - else + } + else { + cv_ProtoLastResult = 0; EndDdeCmnd(0); + } FreeFileVar_(&FileVar); } +int ProtoGetLastResult(void) +{ + return cv_ProtoLastResult; +} + /* ダイアログを中央に移動する */ static void CenterCommonDialog(HWND hDlg) { diff --git a/teraterm/teraterm/tests/test_agent_jsonrpc.c b/teraterm/teraterm/tests/test_agent_jsonrpc.c index c8be57a62..7a50ff5a3 100644 --- a/teraterm/teraterm/tests/test_agent_jsonrpc.c +++ b/teraterm/teraterm/tests/test_agent_jsonrpc.c @@ -35,6 +35,13 @@ typedef struct { size_t last_text_len; unsigned char last_bytes[256]; size_t last_bytes_len; + /* recorded last zmodem_send */ + char last_zsend_path[256]; + int last_zsend_binary; + int last_zsend_called; + /* transfer state reported by get_status */ + int xfer_active; + int xfer_last_result; } Fake; static int fk_check_token(void *ctx, const char *token) @@ -54,6 +61,8 @@ static int fk_get_status(void *ctx, const char *session, AgentStatus *out) out->cols = 80; out->rows = 24; strcpy(out->host, "example.com"); + out->xfer_active = f->xfer_active; + out->xfer_last_result = f->xfer_last_result; return 0; } @@ -127,6 +136,21 @@ static int fk_send_bytes(void *ctx, const char *session, const void *data, size_ return (int)len; } +static int fk_zmodem_send(void *ctx, const char *session, const char *pathU8, int binary) +{ + (void)session; + Fake *f = (Fake *)ctx; + if (!f->connected) + return AGENT_ERR_NOTCONN; + if (!f->allow_send) + return AGENT_ERR_NOTALLOWED; + strncpy(f->last_zsend_path, pathU8, sizeof(f->last_zsend_path) - 1); + f->last_zsend_path[sizeof(f->last_zsend_path) - 1] = 0; + f->last_zsend_binary = binary; + f->last_zsend_called = 1; + return 0; /* transfer started */ +} + static void fill_backend(AgentBackend *be, Fake *f) { memset(be, 0, sizeof(*be)); @@ -139,6 +163,7 @@ static void fill_backend(AgentBackend *be, Fake *f) be->read_scrollback = fk_read_scrollback; be->send_text = fk_send_text; be->send_bytes = fk_send_bytes; + be->zmodem_send = fk_zmodem_send; } /* ---- helpers ---- */ @@ -326,6 +351,119 @@ static void test_send_not_allowed(void) cJSON_Delete(r); } +static void test_status_transfer(void) +{ + AgentConn conn = {0}; + conn.authed = 1; + char buf[1024]; + struct { + int active, last; + const char *state; + int has_ok, ok; + } cases[] = { + {0, -1, "idle", 0, 0}, + {1, -1, "active", 0, 0}, + {0, 1, "done", 1, 1}, + {0, 0, "done", 1, 0}, + }; + for (int i = 0; i < 4; i++) { + Fake f = {0}; + f.connected = 1; + f.xfer_active = cases[i].active; + f.xfer_last_result = cases[i].last; + AgentBackend be; + fill_backend(&be, &f); + cJSON *r = dispatch(&be, &conn, "{\"id\":30,\"method\":\"status\"}", buf, sizeof(buf)); + CHECK(r != NULL); + cJSON *res = cJSON_GetObjectItem(r, "result"); + cJSON *t = cJSON_GetObjectItem(res, "transfer"); + CHECK(t != NULL); + cJSON *stt = cJSON_GetObjectItem(t, "state"); + CHECK(cJSON_IsString(stt) && strcmp(stt->valuestring, cases[i].state) == 0); + cJSON *ok = cJSON_GetObjectItem(t, "ok"); + if (cases[i].has_ok) { + CHECK(cJSON_IsBool(ok)); + CHECK((cJSON_IsTrue(ok) ? 1 : 0) == cases[i].ok); + } else { + CHECK(ok == NULL); + } + cJSON_Delete(r); + } +} + +static void test_zmodem_send(void) +{ + AgentConn conn = {0}; + conn.authed = 1; + char buf[1024]; + + /* happy path: armed, path parsed, binary defaults on, backend invoked once */ + { + Fake f = {0}; + f.connected = 1; + f.allow_send = 1; + AgentBackend be; + fill_backend(&be, &f); + cJSON *r = dispatch(&be, &conn, + "{\"id\":20,\"method\":\"zmodem_send\",\"params\":{\"path\":\"C:\\\\payload\\\\fw.bin\"}}", + buf, sizeof(buf)); + CHECK(r != NULL); + CHECK(result_ok(r)); + CHECK(f.last_zsend_called == 1); + CHECK(strcmp(f.last_zsend_path, "C:\\payload\\fw.bin") == 0); + CHECK(f.last_zsend_binary == 1); + cJSON_Delete(r); + } + + /* explicit binary:false is honored */ + { + Fake f = {0}; + f.connected = 1; + f.allow_send = 1; + AgentBackend be; + fill_backend(&be, &f); + cJSON *r = dispatch(&be, &conn, + "{\"id\":21,\"method\":\"zmodem_send\",\"params\":{\"path\":\"/tmp/x\",\"binary\":false}}", + buf, sizeof(buf)); + CHECK(r != NULL); + CHECK(result_ok(r)); + CHECK(f.last_zsend_called == 1); + CHECK(f.last_zsend_binary == 0); + cJSON_Delete(r); + } + + /* gate: send not armed -> error, backend NOT invoked */ + { + Fake f = {0}; + f.connected = 1; + f.allow_send = 0; + AgentBackend be; + fill_backend(&be, &f); + cJSON *r = dispatch(&be, &conn, + "{\"id\":22,\"method\":\"zmodem_send\",\"params\":{\"path\":\"C:\\\\x\"}}", + buf, sizeof(buf)); + CHECK(r != NULL); + CHECK(!result_ok(r)); + CHECK(f.last_zsend_called == 0); + cJSON_Delete(r); + } + + /* malformed: missing path -> error, backend NOT invoked */ + { + Fake f = {0}; + f.connected = 1; + f.allow_send = 1; + AgentBackend be; + fill_backend(&be, &f); + cJSON *r = dispatch(&be, &conn, + "{\"id\":23,\"method\":\"zmodem_send\",\"params\":{}}", buf, sizeof(buf)); + CHECK(r != NULL); + CHECK(!result_ok(r)); + CHECK(f.last_zsend_called == 0); + cJSON_Delete(r); + } +} + static void test_unknown_and_malformed(void) { Fake f = {0}; @@ -403,6 +541,8 @@ int main(void) test_send_bytes(); test_send_key(); test_send_not_allowed(); + test_status_transfer(); + test_zmodem_send(); test_unknown_and_malformed(); test_list_sessions(); test_b64_roundtrip(); diff --git a/teraterm/teraterm/tests/test_agent_mcp.c b/teraterm/teraterm/tests/test_agent_mcp.c index 1f8a2bfbd..f5d55f491 100644 --- a/teraterm/teraterm/tests/test_agent_mcp.c +++ b/teraterm/teraterm/tests/test_agent_mcp.c @@ -123,15 +123,17 @@ static void test_tools_list(void) cJSON *r = cJSON_Parse(out); cJSON *tools = cJSON_GetObjectItem(cJSON_GetObjectItem(r, "result"), "tools"); CHECK(cJSON_IsArray(tools)); - CHECK(cJSON_GetArraySize(tools) == 7); - int found_send_line = 0; + CHECK(cJSON_GetArraySize(tools) == 8); + int found_send_line = 0, found_zmodem_send = 0; cJSON *t; cJSON_ArrayForEach(t, tools) { const char *nm = cJSON_GetStringValue(cJSON_GetObjectItem(t, "name")); CHECK(cJSON_GetObjectItem(t, "inputSchema") != NULL); if (nm && strcmp(nm, "send_line") == 0) found_send_line = 1; + if (nm && strcmp(nm, "zmodem_send") == 0) found_zmodem_send = 1; } CHECK(found_send_line); + CHECK(found_zmodem_send); cJSON_Delete(r); }