The DWE protocol

dwed speaks newline-delimited JSON: one JSON object per line in each direction, with each response carrying the id of the request it answers.

JSON rather than a compact binary encoding is a deliberate trade. When dwed itself is the thing misbehaving, the protocol has to stay drivable by hand — and being able to type at it and read what comes back is worth more than the bytes a binary framing would save:

$ nc 10.0.0.5 4820
{"id":1,"op":"info"}
{"id":1,"ok":{"reply":"info","protocol_version":1,"dwed_version":"0.1.0",...}}

Framing #

A request is an object with an id and an op, plus that op's arguments inline:

{"id": 7, "op": "exec", "argv": ["ls", "-l", "/system"]}

id is chosen by the client and echoed back untouched. Requests on one connection are answered in order; concurrency comes from opening more connections, or from detaching work.

A response carries the same id and exactly one of ok or error:

{"id": 7, "ok": {"reply": "exec", "exit": 0, "stdout": "", "stderr": "", "truncated": false}}
{"id": 7, "error": {"code": "no_such_job", "message": "job 9 is unknown"}}

The reply field names the shape of the payload. It is there because several replies carry the same fields — exec and job.output both have stdout, stderr and an optional exit — and a reader should never have to guess which one it is holding from shape alone.

A request that does not parse is still answered, with id: 0 and a bad_request error. Silence would leave a client waiting on an id that is never coming, which presents as a hang — the one symptom hardest to tell apart from the bug being investigated.

Bytes #

Every field carrying payload bytes is base64: file contents, and captured stdout/stderr alike.

Output is base64 rather than a JSON string because a guest command's output is not guaranteed to be valid UTF-8, and a tool that mangles a binary is worse than one that refuses it. Clients decode and write raw bytes back out, so the encoding never reaches whoever is driving the tool.

Operations #

exec #

Run a command. Synchronous unless detach is set.

Field
argvrequiredProgram and arguments. Executed directly — not through a shell.
cwdoptionalWorking directory.
envoptionalExtra environment, as [name, value] pairs, on top of the service's own.
stdinoptionalBytes written to the child's stdin, which is then closed.
detachoptionalReturn a job handle immediately instead of waiting.

Replies exec with exit (null if signalled), signal, stdout, stderr and truncated; or job with a job handle when detach was set.

Both output streams are captured concurrently. A child that fills one pipe while the other goes undrained would otherwise deadlock, and "the command hung" is the least useful thing a debugging tool can report.

job.list #

No arguments. Replies job_list with a jobs array of {job, argv, running, exit, signal, started}.

Jobs outlive the connection that created them. They do not outlive dwed itself.

job.output #

Field
jobrequiredThe handle.
since_stdoutoptionalResume stdout from this byte offset.
since_stderroptionalResume stderr from this byte offset.

Replies job_output with stdout, stderr, stdout_next, stderr_next, running, exit, signal and truncated.

The *_next values are what to pass as since_* on the following call. Cursors are the caller's to keep: dwed has no sessions and cannot distinguish two callers, so a server-side cursor would have concurrent readers consuming each other's output. An offset past the end is clamped rather than rejected.

job.signal #

{job, signal} — deliver a signal to a running job. Replies done. A job that has already exited gives not_running.

file.read #

{path} — replies file_read with bytes and mode.

file.write #

{path, bytes, mode?} — replies done. mode is applied after writing.

info #

No arguments. Replies info:

Field
protocol_versionThe version dwed speaks.
dwed_versionIts own release.
boot_idDistinguishes one boot from the next.
uptimeSeconds since boot.

boot_id is the field worth using. It lets a client tell "the machine rebooted under me" from "my connection dropped" — two situations that look identical from the socket and mean entirely different things.

Errors #

code
bad_requestMalformed JSON, an unknown op, or empty argv.
ioAn underlying system call failed. Carries errno.
no_such_jobNo job with that handle.
not_runningThe job has already exited.

errno is carried separately from the message so a client can match on the cause rather than parse English.

Transports #

vsock is the default, on port 4820. It needs no networking in the guest — which matters, because a machine whose networking is part of what broke is squarely one of the cases DWE exists for. dwed binds VMADDR_CID_ANY: a guest does not reliably know its own context id, and does not need to.

TCP is available on the same port but is opt-in (dwed --tcp 0.0.0.0:4820). Listening by default would hand the machine to anyone who can route to it, which is more than starting a service should quietly do given there is nothing behind it.

The protocol is identical over either.

Versioning #

PROTOCOL_VERSION is bumped whenever any wire type changes shape. A client compares it against info and reports a mismatch rather than misparsing a response it half understands.

There is no negotiation and no compatibility window. Both halves ship from one repository and are built together; a version check is there to give a clear error, not to bridge a gap.

Edit this page