@zswarm/core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/exec.d.ts CHANGED
@@ -40,7 +40,7 @@ export type SshTarget = {
40
40
  /** Concrete IPC temp, or `auto` to read `--server` off a live zellij process. */
41
41
  tmp?: string;
42
42
  /** `interactive` runs the CLI in the Windows desktop session (schtasks /IT). */
43
- mode?: "ssh" | "interactive";
43
+ mode?: "ssh" | "interactive" | "gate";
44
44
  remoteShell?: "cmd" | "sh";
45
45
  };
46
46
  export declare function quoteRemoteArg(arg: string, shell: RemoteShell): string;
package/dist/host/cli.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { ZellijError } from "../errors.js";
3
3
  import { createStateStore, defaultStateDir } from "../state.js";
4
4
  import { crewCheck, crewClient, crewDown, crewStart, crewStatus, crewUp } from "./crew.js";
5
+ import { GATE_USAGE, gateCommand } from "./gate.js";
5
6
  import { hostDoctor, hostInstall, hostInstallArgs, hostScriptPath } from "./host.js";
6
7
  import { listLeases } from "./lease.js";
7
8
  import { cancelAllTo, cancelRelay, listRelays, pruneRelays, runRelayDir, startRelay, waitForRelay } from "./relay.js";
@@ -288,6 +289,10 @@ const COMMANDS = {
288
289
  ].join("\n"),
289
290
  run: host,
290
291
  },
292
+ gate: {
293
+ usage: GATE_USAGE,
294
+ run: (argv) => gateCommand(argv, (a) => parseHostArgs(a)),
295
+ },
291
296
  };
292
297
  export const HOST_COMMANDS = Object.keys(COMMANDS);
293
298
  export function hostUsage() {
@@ -0,0 +1,25 @@
1
+ import type { DispatchDeps, OpsResult } from "../ops/types.js";
2
+ type Dispatch = (args: Record<string, unknown>, injected: undefined, deps: DispatchDeps) => Promise<OpsResult>;
3
+ /**
4
+ * The forced command behind a gate key: check one request against gate.json,
5
+ * then send locally. The sender's origin keeps its session and pane, but its
6
+ * host is the key-bound id and it is stamped `via=gate`.
7
+ */
8
+ export declare function runGate(input: {
9
+ id: string;
10
+ request: string;
11
+ env?: NodeJS.ProcessEnv;
12
+ dispatch?: Dispatch;
13
+ now?: () => Date;
14
+ }): Promise<OpsResult>;
15
+ type Flags = Record<string, string | true>;
16
+ export declare function gateAllow(id: string, flags: Flags, env?: NodeJS.ProcessEnv): Record<string, unknown>;
17
+ export declare function gateRevoke(id: string, flags: Flags, env?: NodeJS.ProcessEnv): Record<string, unknown>;
18
+ export declare function gateList(flags: Flags, env?: NodeJS.ProcessEnv): Record<string, unknown>;
19
+ export declare const GATE_USAGE: string;
20
+ /** `zswarm gate …`; resolves to the exit code. */
21
+ export declare function gateCommand(argv: string[], parse: (argv: string[]) => {
22
+ flags: Flags;
23
+ rest: string[];
24
+ }): Promise<number>;
25
+ export {};
@@ -0,0 +1,168 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { ZellijError } from "../errors.js";
4
+ import { dispatchZswarm } from "../ops/dispatch.js";
5
+ import { GATE_MAX_REQUEST_BYTES, allowInConfig, authorizeGateRequest, defaultAuthorizedKeysPath, gateConfigPath, gateForcedCommand, gateKeyLine, idIsAllowed, listGateKeys, loadGateConfig, removeGateKey, revokeInConfig, saveGateConfig, upsertGateKey, validateGateId, writeAuthorizedKeys, } from "../ops/gate.js";
6
+ import { defaultStateDir } from "../state.js";
7
+ const RESERVED = new Set(["allow", "revoke", "list", "help"]);
8
+ /**
9
+ * The forced command behind a gate key: check one request against gate.json,
10
+ * then send locally. The sender's origin keeps its session and pane, but its
11
+ * host is the key-bound id and it is stamped `via=gate`.
12
+ */
13
+ export async function runGate(input) {
14
+ const env = input.env ?? process.env;
15
+ const dispatch = input.dispatch ?? ((a, i, d) => dispatchZswarm(a, i, d));
16
+ const at = (input.now ?? (() => new Date()))().toISOString();
17
+ let result;
18
+ let target = "";
19
+ try {
20
+ const id = validateGateId(input.id);
21
+ const allowed = authorizeGateRequest(loadGateConfig(gateConfigPath(env)), id, input.request);
22
+ target = `${allowed.session}/${allowed.to}`;
23
+ const a = allowed.args;
24
+ result = await dispatch({
25
+ op: "send",
26
+ session: allowed.session,
27
+ to: allowed.to,
28
+ body: a.body,
29
+ ...(typeof a.from === "string" ? { from: a.from } : {}),
30
+ ...(typeof a.submit === "string" ? { submit: a.submit } : {}),
31
+ ...(typeof a.harness === "string" ? { harness: a.harness } : {}),
32
+ ...(a.ifIdle === true ? { ifIdle: true } : {}),
33
+ origin: a.origin,
34
+ local: true,
35
+ }, undefined, { env, gateId: id });
36
+ }
37
+ catch (err) {
38
+ const e = err instanceof ZellijError ? err : new ZellijError("gate_failed", String(err));
39
+ result = { ok: false, error: { code: e.code, message: e.message, ...(e.details ? { details: e.details } : {}) } };
40
+ }
41
+ try {
42
+ const log = join(defaultStateDir(env), "gate.log");
43
+ mkdirSync(dirname(log), { recursive: true });
44
+ const submitted = result.ok ? result.data?.submitted : undefined;
45
+ appendFileSync(log, `${JSON.stringify({ at, id: input.id, target, bytes: Buffer.byteLength(input.request), ok: result.ok, submitted, error: result.ok ? undefined : result.error.code })}\n`);
46
+ }
47
+ catch {
48
+ // the log is a convenience; delivery already happened or failed on its own
49
+ }
50
+ return result;
51
+ }
52
+ async function readStdin(limit) {
53
+ const chunks = [];
54
+ let size = 0;
55
+ for await (const chunk of process.stdin) {
56
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
57
+ size += b.length;
58
+ if (size > limit)
59
+ throw new ZellijError("gate_request_too_large", `gate requests are limited to ${limit} bytes`);
60
+ chunks.push(b);
61
+ }
62
+ return Buffer.concat(chunks).toString("utf8");
63
+ }
64
+ function cliPath() {
65
+ const script = process.argv[1] ?? "";
66
+ try {
67
+ return realpathSync(script);
68
+ }
69
+ catch {
70
+ return script;
71
+ }
72
+ }
73
+ const s = (v) => (typeof v === "string" ? v : undefined);
74
+ export function gateAllow(id, flags, env = process.env) {
75
+ id = validateGateId(id);
76
+ if (RESERVED.has(id))
77
+ throw new ZellijError("bad_gate_id", `${id} is a gate subcommand, not an id`);
78
+ const session = s(flags["--session"]);
79
+ const pane = s(flags["--pane"]);
80
+ if (!session || !pane)
81
+ throw new ZellijError("usage", "gate allow needs --session and --pane (the pane title or id senders will use with --to)");
82
+ const keyFile = s(flags["--key-file"]);
83
+ const key = s(flags["--key"]) ?? (keyFile ? readFileSync(keyFile, "utf8") : undefined);
84
+ if (!key)
85
+ throw new ZellijError("usage", "gate allow needs --key 'ssh-ed25519 AAAA…' or --key-file PATH (the sender's public key)");
86
+ const command = gateForcedCommand({ id, node: process.execPath, cli: cliPath(), env });
87
+ const line = gateKeyLine({ id, key, command, from: s(flags["--from"]) });
88
+ const keysPath = s(flags["--authorized-keys"]) ?? defaultAuthorizedKeysPath(env);
89
+ mkdirSync(dirname(keysPath), { recursive: true, mode: 0o700 });
90
+ writeAuthorizedKeys(keysPath, upsertGateKey(existsSync(keysPath) ? readFileSync(keysPath, "utf8") : "", id, line));
91
+ const configPath = gateConfigPath(env);
92
+ const config = allowInConfig(loadGateConfig(configPath), id, session, pane);
93
+ saveGateConfig(configPath, config);
94
+ return { id, session, pane, from: s(flags["--from"]) ?? null, authorizedKeys: keysPath, config: configPath, command };
95
+ }
96
+ export function gateRevoke(id, flags, env = process.env) {
97
+ id = validateGateId(id);
98
+ const session = s(flags["--session"]);
99
+ const pane = s(flags["--pane"]);
100
+ if (Boolean(session) !== Boolean(pane))
101
+ throw new ZellijError("usage", "gate revoke takes both --session and --pane, or neither (all targets)");
102
+ const configPath = gateConfigPath(env);
103
+ const config = revokeInConfig(loadGateConfig(configPath), id, session && pane ? { session, pane } : undefined);
104
+ saveGateConfig(configPath, config);
105
+ const keysPath = s(flags["--authorized-keys"]) ?? defaultAuthorizedKeysPath(env);
106
+ const keyRemoved = !idIsAllowed(config, id) && existsSync(keysPath);
107
+ if (keyRemoved)
108
+ writeAuthorizedKeys(keysPath, removeGateKey(readFileSync(keysPath, "utf8"), id));
109
+ return { id, config: configPath, keyRemoved, stillAllowed: config.targets.filter((t) => t.allow.includes(id)).map((t) => `${t.session}/${t.pane}`) };
110
+ }
111
+ export function gateList(flags, env = process.env) {
112
+ const keysPath = s(flags["--authorized-keys"]) ?? defaultAuthorizedKeysPath(env);
113
+ const configPath = gateConfigPath(env);
114
+ return {
115
+ config: configPath,
116
+ targets: loadGateConfig(configPath).targets,
117
+ authorizedKeys: keysPath,
118
+ keys: existsSync(keysPath) ? listGateKeys(readFileSync(keysPath, "utf8")) : [],
119
+ };
120
+ }
121
+ export const GATE_USAGE = [
122
+ "zswarm gate allow ID (--key 'ssh-ed25519 AAAA…' | --key-file PUB) --session S --pane P [--from ADDRS] [--authorized-keys PATH]",
123
+ " on the receiving machine: let the key send to pane P in session S as ID. Writes a forced-command line",
124
+ " (send only: no shell, no pty, no forwarding) to authorized_keys and the target to gate.json (ZSWARM_GATE_CONFIG,",
125
+ " default ~/.zswarm/gate.json); --from limits the source addresses (sshd from= patterns)",
126
+ "zswarm gate revoke ID [--session S --pane P] drop ID from one target or all; the key line goes when nothing is left",
127
+ "zswarm gate list targets, allowed ids and gate keys",
128
+ "zswarm gate ID the forced command itself: reads one request on stdin and sends locally (sshd runs this)",
129
+ "sender: zswarm send --gate DEST --session S --to P --body … (DEST: ssh alias or user@host using that key)",
130
+ ].join("\n");
131
+ /** `zswarm gate …`; resolves to the exit code. */
132
+ export async function gateCommand(argv, parse) {
133
+ const [sub, ...args] = argv;
134
+ const print = (r) => {
135
+ process.stdout.write(`${JSON.stringify(r)}\n`);
136
+ return r.ok ? 0 : 1;
137
+ };
138
+ const run = (work) => {
139
+ try {
140
+ return print({ ok: true, data: work() });
141
+ }
142
+ catch (err) {
143
+ const e = err instanceof ZellijError ? err : new ZellijError("failed", String(err));
144
+ return print({ ok: false, error: { code: e.code, message: e.message } });
145
+ }
146
+ };
147
+ if (sub === "allow" || sub === "revoke" || sub === "list") {
148
+ const { flags, rest } = parse(args);
149
+ if (sub === "list")
150
+ return run(() => gateList(flags));
151
+ const id = rest[0] ?? "";
152
+ return run(() => (sub === "allow" ? gateAllow(id, flags) : gateRevoke(id, flags)));
153
+ }
154
+ if (!sub) {
155
+ process.stderr.write(`${GATE_USAGE}\n`);
156
+ return 2;
157
+ }
158
+ // Receiver: sshd's forced command. Anything the client asked to run is ignored.
159
+ let request;
160
+ try {
161
+ request = await readStdin(GATE_MAX_REQUEST_BYTES);
162
+ }
163
+ catch (err) {
164
+ const e = err instanceof ZellijError ? err : new ZellijError("gate_failed", String(err));
165
+ return print({ ok: false, error: { code: e.code, message: e.message } });
166
+ }
167
+ return print(await runGate({ id: sub, request }));
168
+ }
package/dist/index.d.ts CHANGED
@@ -35,6 +35,8 @@ export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCT
35
35
  export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
36
36
  export { cliUsage, commandUsage, wantsHelp, extractOp, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, type OpName, type ParamSpec, type ParamType, } from "./schema.js";
37
37
  export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/cli.js";
38
+ export { GATE_USAGE, gateAllow, gateList, gateRevoke, runGate } from "./host/gate.js";
39
+ export { GATE_PROTOCOL, allowInConfig, authorizeGateRequest, buildGateRequest, forwardGate, gateConfigPath, gateForcedCommand, gateKeyLine, isGateMode, listGateKeys, loadGateConfig, parsePublicKey, removeGateKey, revokeInConfig, upsertGateKey, validateGateId, type GateConfig, type GateRequest, type GateTarget, } from "./ops/gate.js";
38
40
  export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
39
41
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
40
42
  export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, type Runner, type ServiceAccount, type ServiceHost, type ServiceKind, type ServiceSpec, type ServiceState, } from "./host/services.js";
package/dist/index.js CHANGED
@@ -33,6 +33,8 @@ export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCT
33
33
  export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
34
34
  export { cliUsage, commandUsage, wantsHelp, extractOp, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, } from "./schema.js";
35
35
  export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/cli.js";
36
+ export { GATE_USAGE, gateAllow, gateList, gateRevoke, runGate } from "./host/gate.js";
37
+ export { GATE_PROTOCOL, allowInConfig, authorizeGateRequest, buildGateRequest, forwardGate, gateConfigPath, gateForcedCommand, gateKeyLine, isGateMode, listGateKeys, loadGateConfig, parsePublicKey, removeGateKey, revokeInConfig, upsertGateKey, validateGateId, } from "./ops/gate.js";
36
38
  export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
37
39
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, } from "./host/serve-service.js";
38
40
  export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, } from "./host/services.js";
@@ -16,8 +16,11 @@ export type SenderOrigin = {
16
16
  session: string | null;
17
17
  /** Sender's pane id, e.g. "terminal_3". */
18
18
  pane: string | null;
19
- /** "serve" only when this host received the op over serve. */
20
- via: "serve" | null;
19
+ /**
20
+ * "serve" when this host received the op over serve; "gate" when it came
21
+ * through a gate key, whose id (not the sender's claim) is `host`.
22
+ */
23
+ via: "serve" | "gate" | null;
21
24
  };
22
25
  /**
23
26
  * Clean one origin part for the `[zswarm from=…]` header: non-strings and
@@ -66,7 +66,7 @@ export function originSuffix(origin) {
66
66
  const src = session && pane ? `${session}/${pane}` : (pane ?? session ?? null);
67
67
  return ((src ? " src=" + src : "") +
68
68
  (host ? " host=" + host : "") +
69
- (via === "serve" ? " via=serve" : ""));
69
+ (via === "serve" || via === "gate" ? " via=" + via : ""));
70
70
  }
71
71
  /**
72
72
  * An args-supplied origin, cleaned again (a direct caller may skip dispatch),
@@ -80,7 +80,7 @@ function originArg(raw) {
80
80
  host: sanitizeOriginPart(record.host),
81
81
  session: sanitizeOriginPart(record.session),
82
82
  pane: sanitizeOriginPart(record.pane),
83
- via: record.via === "serve" ? "serve" : null,
83
+ via: record.via === "serve" || record.via === "gate" ? record.via : null,
84
84
  };
85
85
  }
86
86
  /** Fixed send windows before adaptive scaling; explicit flags always win. */
@@ -30,6 +30,7 @@ import { DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLogon, uninstallServeLogo
30
30
  import { serveCallTimeout, } from "./serve.js";
31
31
  import { doctorOp } from "./doctor.js";
32
32
  import { forwardServe } from "./serve-tunnel.js";
33
+ import { forwardGate, isGateMode } from "./gate.js";
33
34
  import { installServeService, uninstallServeService } from "../host/serve-service.js";
34
35
  /**
35
36
  * The harness busy marker a dumped screen shows for `pane`, or null. The one
@@ -47,13 +48,21 @@ export function paneBusyMarker(screen, pane, harness) {
47
48
  export function resolveInvocationEnv(args, base) {
48
49
  const local = isTrue(args.local);
49
50
  const sshDest = typeof args.ssh === "string" && args.ssh.trim() ? args.ssh.trim() : null;
51
+ const gateDest = typeof args.gate === "string" && args.gate.trim() ? args.gate.trim() : null;
50
52
  const serveAddress = optionalString(args.serveAddress);
51
- if (Number(local) + Number(Boolean(sshDest)) + Number(Boolean(serveAddress)) > 1) {
52
- throw new ZellijError("usage", "choose only one of --local, --ssh, or --serve");
53
+ if (Number(local) + Number(Boolean(sshDest)) + Number(Boolean(gateDest)) + Number(Boolean(serveAddress)) > 1) {
54
+ throw new ZellijError("usage", "choose only one of --local, --ssh, --gate, or --serve");
53
55
  }
54
- if (!local && !sshDest && !serveAddress)
56
+ if (!local && !sshDest && !gateDest && !serveAddress)
55
57
  return base;
56
58
  const env = { ...base };
59
+ if (gateDest) {
60
+ env.ZSWARM_SSH = gateDest;
61
+ env.ZSWARM_SSH_MODE = "gate";
62
+ delete env.ZSWARM_SERVE;
63
+ delete env.ZSWARM_SERVE_TOKEN;
64
+ return env;
65
+ }
57
66
  if (serveAddress) {
58
67
  env.ZSWARM_SERVE = serveAddress;
59
68
  delete env.ZSWARM_SSH;
@@ -72,6 +81,9 @@ export function resolveInvocationEnv(args, base) {
72
81
  return env;
73
82
  }
74
83
  env.ZSWARM_SSH = sshDest;
84
+ // --ssh means per-call Zellij forwarding, even when the env carries a gate mode.
85
+ if ((env.ZSWARM_SSH_MODE ?? "").trim().toLowerCase() === "gate")
86
+ delete env.ZSWARM_SSH_MODE;
75
87
  delete env.ZSWARM_SERVE;
76
88
  delete env.ZSWARM_SERVE_TOKEN;
77
89
  return env;
@@ -263,6 +275,18 @@ async function dispatchOperation(args, injected, deps, context, env) {
263
275
  // Policy gates the op before anything touches the session.
264
276
  assertOpAllowed(policy, op);
265
277
  assertSshGitAllowed(env, op, args);
278
+ // A gate key can only send: refuse anything else before it reaches ssh.
279
+ if (!injected && !env.ZSWARM_SERVE?.trim() && isGateMode(env)) {
280
+ if (op !== "send") {
281
+ throw new ZellijError("gate_op_unsupported", `${op} is not available through a gate (ZSWARM_SSH_MODE=gate / --gate only carries send); use --ssh or --serve`);
282
+ }
283
+ return await forwardGate({
284
+ env,
285
+ args: attachKnownSender(args, env),
286
+ timeoutMs: serveCallTimeout(args),
287
+ signal,
288
+ });
289
+ }
266
290
  if (op === "doctor") {
267
291
  // Before serve forwarding and session resolution so a dead tunnel still
268
292
  // yields controller findings. Policy already ran.
@@ -358,7 +382,7 @@ async function dispatchOperation(args, injected, deps, context, env) {
358
382
  // Only ops that build a sender header or compare against the caller's own
359
383
  // pane need the resolved self session; read-only ops must not pay a
360
384
  // Zellij round-trip for it.
361
- if (deps.viaServe !== true && SELF_SESSION_OPS.has(op) && typeof client.resolveSelf === "function") {
385
+ if (deps.viaServe !== true && !deps.gateId && SELF_SESSION_OPS.has(op) && typeof client.resolveSelf === "function") {
362
386
  // The pane's ZELLIJ_SESSION_NAME can be stale after a rename; the header
363
387
  // must name the live session peers can reply to.
364
388
  const self = await client.resolveSelf();
@@ -791,7 +815,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
791
815
  args =
792
816
  deps.viaServe === true
793
817
  ? { ...args, origin: originFromServeRequest(args.origin) }
794
- : { ...args, origin: resolveSenderOrigin(env) };
818
+ : deps.gateId
819
+ ? { ...args, origin: { ...originFromServeRequest(args.origin), host: deps.gateId, via: "gate" } }
820
+ : { ...args, origin: resolveSenderOrigin(env) };
795
821
  const context = invocationContext(args, env, injected?.transport);
796
822
  const result = await dispatchOperation(args, injected, deps, context, env);
797
823
  if (context.transport === "serve" && result.context) {
@@ -0,0 +1,101 @@
1
+ import type { OpsResult } from "./types.js";
2
+ /**
3
+ * Gate: a send-only SSH key. The receiving machine puts the key in
4
+ * authorized_keys with `command="… zswarm gate <id>"`, so whatever the client
5
+ * asks to run, sshd starts the gate with an id the sender cannot choose. The
6
+ * gate reads one JSON request on stdin, checks it against the allowlist in
7
+ * gate.json and performs a local send. Nothing else is reachable with the key:
8
+ * no shell, no screen reads, no other pane.
9
+ *
10
+ * The sender side is `ZSWARM_SSH_MODE=gate` (or `--gate DEST`): one ssh call
11
+ * carrying the whole send, instead of per-call Zellij forwarding.
12
+ */
13
+ export declare const GATE_PROTOCOL = 1;
14
+ /** What the client asks sshd to run; the forced command replaces it. */
15
+ export declare const GATE_REMOTE_COMMAND = "zswarm-gate";
16
+ /** Largest request the gate reads (body plus envelope). */
17
+ export declare const GATE_MAX_REQUEST_BYTES: number;
18
+ export declare const GATE_KEY_COMMENT_PREFIX = "zswarm-gate:";
19
+ /** Send parameters a gate passes on. Guards (force, allow-self, expect) and prefix control stay with the receiver. */
20
+ declare const GATE_SEND_FIELDS: readonly ["session", "to", "body", "from", "submit", "harness", "ifIdle"];
21
+ export type GateRequest = {
22
+ v: number;
23
+ op: "send";
24
+ args: Partial<Record<(typeof GATE_SEND_FIELDS)[number], unknown>> & {
25
+ origin?: unknown;
26
+ };
27
+ };
28
+ export type GateTarget = {
29
+ session: string;
30
+ pane: string;
31
+ allow: string[];
32
+ };
33
+ export type GateConfig = {
34
+ targets: GateTarget[];
35
+ };
36
+ export declare function validateGateId(raw: unknown): string;
37
+ export declare function isGateMode(env: NodeJS.ProcessEnv): boolean;
38
+ /** The request a gate client sends: only the whitelisted send fields, plus the sender's own origin. */
39
+ export declare function buildGateRequest(args: Record<string, unknown>): GateRequest;
40
+ /** Sender side: one ssh call, the request on stdin, the receiver's send result on stdout. */
41
+ export declare function forwardGate(input: {
42
+ env: NodeJS.ProcessEnv;
43
+ args: Record<string, unknown>;
44
+ timeoutMs: number;
45
+ signal?: AbortSignal;
46
+ }): Promise<OpsResult>;
47
+ export declare function gateConfigPath(env?: NodeJS.ProcessEnv): string;
48
+ export declare function loadGateConfig(path: string): GateConfig;
49
+ export declare function saveGateConfig(path: string, config: GateConfig): void;
50
+ /** Add `id` to the target (created when new). */
51
+ export declare function allowInConfig(config: GateConfig, id: string, session: string, pane: string): GateConfig;
52
+ /** Remove `id` from one target, or from all; targets left with nobody are dropped. */
53
+ export declare function revokeInConfig(config: GateConfig, id: string, only?: {
54
+ session: string;
55
+ pane: string;
56
+ }): GateConfig;
57
+ export declare function idIsAllowed(config: GateConfig, id: string): boolean;
58
+ /**
59
+ * Receiver side: parse and check one request for the key-bound `id`.
60
+ * Returns the send arguments the gate may run; throws `gate_denied` otherwise.
61
+ */
62
+ export declare function authorizeGateRequest(config: GateConfig, id: string, raw: string): {
63
+ session: string;
64
+ to: string;
65
+ args: GateRequest["args"];
66
+ };
67
+ /**
68
+ * The forced command for one id. sshd gives it a bare environment, so the PATH
69
+ * (and on macOS the per-user TMPDIR that Zellij keeps its sockets under) of the
70
+ * process running `gate allow` is written into the command.
71
+ */
72
+ export declare function gateForcedCommand(input: {
73
+ id: string;
74
+ node: string;
75
+ cli: string;
76
+ platform?: NodeJS.Platform;
77
+ env?: NodeJS.ProcessEnv;
78
+ }): string;
79
+ /** `type base64 [comment]` → `type base64`; anything else is refused. */
80
+ export declare function parsePublicKey(raw: string): string;
81
+ export declare function gateKeyLine(input: {
82
+ id: string;
83
+ key: string;
84
+ command: string;
85
+ from?: string;
86
+ }): string;
87
+ /** Replace this id's gate line, or append it. Other lines are kept byte for byte. */
88
+ export declare function upsertGateKey(text: string, id: string, line: string): string;
89
+ export declare function removeGateKey(text: string, id: string): string;
90
+ export declare function listGateKeys(text: string): Array<{
91
+ id: string;
92
+ from: string | null;
93
+ keyType: string;
94
+ }>;
95
+ /**
96
+ * authorized_keys for this account. On Windows an administrator's keys live in
97
+ * %ProgramData%\ssh\administrators_authorized_keys when that file exists.
98
+ */
99
+ export declare function defaultAuthorizedKeysPath(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
100
+ export declare function writeAuthorizedKeys(path: string, text: string, platform?: NodeJS.Platform): void;
101
+ export {};
@@ -0,0 +1,324 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { ZellijError } from "../errors.js";
5
+ import { defaultStateDir } from "../state.js";
6
+ import { resolveSshTarget } from "../zellij/binary.js";
7
+ /**
8
+ * Gate: a send-only SSH key. The receiving machine puts the key in
9
+ * authorized_keys with `command="… zswarm gate <id>"`, so whatever the client
10
+ * asks to run, sshd starts the gate with an id the sender cannot choose. The
11
+ * gate reads one JSON request on stdin, checks it against the allowlist in
12
+ * gate.json and performs a local send. Nothing else is reachable with the key:
13
+ * no shell, no screen reads, no other pane.
14
+ *
15
+ * The sender side is `ZSWARM_SSH_MODE=gate` (or `--gate DEST`): one ssh call
16
+ * carrying the whole send, instead of per-call Zellij forwarding.
17
+ */
18
+ export const GATE_PROTOCOL = 1;
19
+ /** What the client asks sshd to run; the forced command replaces it. */
20
+ export const GATE_REMOTE_COMMAND = "zswarm-gate";
21
+ /** Largest request the gate reads (body plus envelope). */
22
+ export const GATE_MAX_REQUEST_BYTES = 256 * 1024;
23
+ export const GATE_KEY_COMMENT_PREFIX = "zswarm-gate:";
24
+ const GATE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
25
+ /** Send parameters a gate passes on. Guards (force, allow-self, expect) and prefix control stay with the receiver. */
26
+ const GATE_SEND_FIELDS = ["session", "to", "body", "from", "submit", "harness", "ifIdle"];
27
+ export function validateGateId(raw) {
28
+ const id = typeof raw === "string" ? raw.trim() : "";
29
+ if (!GATE_ID.test(id)) {
30
+ throw new ZellijError("bad_gate_id", `gate id must be 1-63 letters, digits, '.', '_' or '-', starting with a letter or digit; got ${JSON.stringify(raw)}`);
31
+ }
32
+ return id;
33
+ }
34
+ export function isGateMode(env) {
35
+ return Boolean(env.ZSWARM_SSH?.trim()) && (env.ZSWARM_SSH_MODE ?? "").trim().toLowerCase() === "gate";
36
+ }
37
+ /** The request a gate client sends: only the whitelisted send fields, plus the sender's own origin. */
38
+ export function buildGateRequest(args) {
39
+ const out = {};
40
+ for (const field of GATE_SEND_FIELDS) {
41
+ if (args[field] !== undefined && args[field] !== null)
42
+ out[field] = args[field];
43
+ }
44
+ if (args.origin && typeof args.origin === "object")
45
+ out.origin = args.origin;
46
+ return { v: GATE_PROTOCOL, op: "send", args: out };
47
+ }
48
+ function lastJsonLine(text) {
49
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
50
+ for (let i = lines.length - 1; i >= 0; i--) {
51
+ const line = lines[i];
52
+ if (!line.startsWith("{"))
53
+ continue;
54
+ try {
55
+ return JSON.parse(line);
56
+ }
57
+ catch {
58
+ // keep looking: a pretty-printed result spans lines
59
+ }
60
+ }
61
+ const start = text.indexOf("{");
62
+ if (start >= 0) {
63
+ try {
64
+ return JSON.parse(text.slice(start));
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+ /** Sender side: one ssh call, the request on stdin, the receiver's send result on stdout. */
73
+ export async function forwardGate(input) {
74
+ const target = resolveSshTarget(input.env);
75
+ if (!target)
76
+ throw new ZellijError("bad_ssh", "gate mode needs ZSWARM_SSH or --gate DEST");
77
+ const request = JSON.stringify(buildGateRequest(input.args));
78
+ if (Buffer.byteLength(request) > GATE_MAX_REQUEST_BYTES) {
79
+ throw new ZellijError("gate_request_too_large", `gate requests are limited to ${GATE_MAX_REQUEST_BYTES} bytes`);
80
+ }
81
+ const argv = [...target.options, target.host, GATE_REMOTE_COMMAND];
82
+ const { code, stdout, stderr } = await new Promise((resolve, reject) => {
83
+ const child = spawn(target.ssh, argv, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
84
+ let stdout = "";
85
+ let stderr = "";
86
+ let settled = false;
87
+ const finish = (fn) => {
88
+ if (settled)
89
+ return;
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ input.signal?.removeEventListener("abort", onAbort);
93
+ fn();
94
+ };
95
+ const timer = setTimeout(() => {
96
+ child.kill();
97
+ finish(() => reject(new ZellijError("timeout", `gate send to ${target.host} took longer than ${input.timeoutMs} ms`)));
98
+ }, input.timeoutMs);
99
+ const onAbort = () => {
100
+ child.kill();
101
+ finish(() => reject(new ZellijError("cancelled", "operation cancelled")));
102
+ };
103
+ input.signal?.addEventListener("abort", onAbort, { once: true });
104
+ child.stdout.setEncoding("utf8").on("data", (d) => (stdout += d));
105
+ child.stderr.setEncoding("utf8").on("data", (d) => (stderr += d));
106
+ child.on("error", (err) => finish(() => reject(new ZellijError("gate_failed", `could not start ${target.ssh}: ${err.message}`))));
107
+ child.on("close", (code) => finish(() => resolve({ code, stdout, stderr })));
108
+ child.stdin.on("error", () => {
109
+ // the remote may close stdin early on refusal; the result still arrives on stdout
110
+ });
111
+ child.stdin.end(request);
112
+ });
113
+ const parsed = lastJsonLine(stdout);
114
+ if (parsed && typeof parsed === "object" && "ok" in parsed)
115
+ return parsed;
116
+ const hint = /Permission denied/i.test(stderr)
117
+ ? " (the key is not authorized there; on the receiver run `zswarm gate allow`)"
118
+ : /not found|No such file/i.test(stderr + stdout)
119
+ ? " (the key reached a shell instead of the gate: its authorized_keys line has no forced command)"
120
+ : "";
121
+ throw new ZellijError("gate_failed", `gate send to ${target.host} failed (exit ${code})${hint}: ${(stderr || stdout).trim().slice(0, 400)}`);
122
+ }
123
+ export function gateConfigPath(env = process.env) {
124
+ return env.ZSWARM_GATE_CONFIG?.trim() || join(defaultStateDir(env), "gate.json");
125
+ }
126
+ export function loadGateConfig(path) {
127
+ if (!existsSync(path))
128
+ return { targets: [] };
129
+ let raw;
130
+ try {
131
+ raw = JSON.parse(readFileSync(path, "utf8"));
132
+ }
133
+ catch (err) {
134
+ throw new ZellijError("gate_config", `${path} is not valid JSON: ${err.message}`);
135
+ }
136
+ const targets = raw?.targets;
137
+ if (!Array.isArray(targets))
138
+ return { targets: [] };
139
+ return {
140
+ targets: targets
141
+ .filter((t) => t !== null && typeof t === "object")
142
+ .map((t) => ({
143
+ session: String(t.session ?? ""),
144
+ pane: String(t.pane ?? ""),
145
+ allow: Array.isArray(t.allow) ? t.allow.map(String) : [],
146
+ }))
147
+ .filter((t) => t.session && t.pane),
148
+ };
149
+ }
150
+ function writeAtomic(path, text, mode = 0o600) {
151
+ mkdirSync(dirname(path), { recursive: true });
152
+ const tmp = `${path}.tmp-${process.pid}`;
153
+ writeFileSync(tmp, text, { mode });
154
+ renameSync(tmp, path);
155
+ }
156
+ export function saveGateConfig(path, config) {
157
+ writeAtomic(path, `${JSON.stringify(config, null, 2)}\n`);
158
+ }
159
+ /** Add `id` to the target (created when new). */
160
+ export function allowInConfig(config, id, session, pane) {
161
+ const targets = config.targets.map((t) => ({ ...t, allow: [...t.allow] }));
162
+ let target = targets.find((t) => t.session === session && t.pane === pane);
163
+ if (!target) {
164
+ target = { session, pane, allow: [] };
165
+ targets.push(target);
166
+ }
167
+ if (!target.allow.includes(id))
168
+ target.allow.push(id);
169
+ return { targets };
170
+ }
171
+ /** Remove `id` from one target, or from all; targets left with nobody are dropped. */
172
+ export function revokeInConfig(config, id, only) {
173
+ return {
174
+ targets: config.targets
175
+ .map((t) => only && (t.session !== only.session || t.pane !== only.pane)
176
+ ? t
177
+ : { ...t, allow: t.allow.filter((a) => a !== id) })
178
+ .filter((t) => t.allow.length > 0),
179
+ };
180
+ }
181
+ export function idIsAllowed(config, id) {
182
+ return config.targets.some((t) => t.allow.includes(id));
183
+ }
184
+ /**
185
+ * Receiver side: parse and check one request for the key-bound `id`.
186
+ * Returns the send arguments the gate may run; throws `gate_denied` otherwise.
187
+ */
188
+ export function authorizeGateRequest(config, id, raw) {
189
+ let request;
190
+ try {
191
+ request = JSON.parse(raw);
192
+ }
193
+ catch {
194
+ throw new ZellijError("gate_bad_request", "the gate reads one JSON request on stdin (send it with ZSWARM_SSH_MODE=gate or --gate)");
195
+ }
196
+ if (!request || typeof request !== "object" || request.v !== GATE_PROTOCOL) {
197
+ throw new ZellijError("gate_bad_request", `unsupported gate protocol ${JSON.stringify(request?.v)}; this receiver speaks ${GATE_PROTOCOL}`);
198
+ }
199
+ if (request.op !== "send") {
200
+ throw new ZellijError("gate_denied", `a gate key may only send; ${JSON.stringify(request.op)} is not allowed`);
201
+ }
202
+ const args = request.args ?? {};
203
+ const session = typeof args.session === "string" ? args.session.trim() : "";
204
+ const to = typeof args.to === "string" ? args.to.trim() : "";
205
+ if (!session || !to) {
206
+ throw new ZellijError("gate_denied", "a gate send must name --session and --to exactly as the receiver allows them");
207
+ }
208
+ if (typeof args.body !== "string" || !args.body.trim()) {
209
+ throw new ZellijError("gate_bad_request", "the message body is empty");
210
+ }
211
+ const target = config.targets.find((t) => t.session === session && t.pane === to);
212
+ if (!target || !target.allow.includes(id)) {
213
+ throw new ZellijError("gate_denied", `gate id ${JSON.stringify(id)} may not send to ${session}/${to}`, {
214
+ allowed: config.targets.filter((t) => t.allow.includes(id)).map((t) => `${t.session}/${t.pane}`),
215
+ });
216
+ }
217
+ return { session, to, args };
218
+ }
219
+ /** Single-quote for a POSIX shell (sshd runs the forced command through the user's shell). */
220
+ function posixQuote(arg) {
221
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
222
+ }
223
+ /**
224
+ * The forced command for one id. sshd gives it a bare environment, so the PATH
225
+ * (and on macOS the per-user TMPDIR that Zellij keeps its sockets under) of the
226
+ * process running `gate allow` is written into the command.
227
+ */
228
+ export function gateForcedCommand(input) {
229
+ const platform = input.platform ?? process.platform;
230
+ const env = input.env ?? process.env;
231
+ if (platform === "win32") {
232
+ // Unquoted paths run the same in cmd and PowerShell (OpenSSH's DefaultShell
233
+ // may be either); a path with spaces needs quoting, which only cmd accepts.
234
+ const q = (s) => {
235
+ const p = s.replace(/\\/g, "/");
236
+ return /\s/.test(p) ? `"${p}"` : p;
237
+ };
238
+ return `${q(input.node)} ${q(input.cli)} gate ${input.id}`;
239
+ }
240
+ const vars = [];
241
+ if (env.PATH)
242
+ vars.push(`PATH=${posixQuote(env.PATH)}`);
243
+ if (platform === "darwin" && env.TMPDIR)
244
+ vars.push(`TMPDIR=${posixQuote(env.TMPDIR)}`);
245
+ if (env.ZSWARM_STATE_DIR)
246
+ vars.push(`ZSWARM_STATE_DIR=${posixQuote(env.ZSWARM_STATE_DIR)}`);
247
+ if (env.ZSWARM_GATE_CONFIG)
248
+ vars.push(`ZSWARM_GATE_CONFIG=${posixQuote(env.ZSWARM_GATE_CONFIG)}`);
249
+ return ["env", ...vars, posixQuote(input.node), posixQuote(input.cli), "gate", input.id].join(" ");
250
+ }
251
+ const KEY_TYPES = /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)$/;
252
+ /** `type base64 [comment]` → `type base64`; anything else is refused. */
253
+ export function parsePublicKey(raw) {
254
+ const parts = raw.trim().split(/\s+/);
255
+ if (parts.length < 2 || !KEY_TYPES.test(parts[0]) || !/^[A-Za-z0-9+/=]+$/.test(parts[1])) {
256
+ throw new ZellijError("bad_gate_key", "expected one OpenSSH public key line, e.g. `ssh-ed25519 AAAA… name`");
257
+ }
258
+ return `${parts[0]} ${parts[1]}`;
259
+ }
260
+ export function gateKeyLine(input) {
261
+ if (input.from && !/^[0-9A-Fa-f:.,/*?!]+$/.test(input.from)) {
262
+ throw new ZellijError("bad_gate_from", `--from takes addresses or patterns separated by commas; got ${JSON.stringify(input.from)}`);
263
+ }
264
+ const opts = [
265
+ // sshd only unescapes \" inside command="…"; every other byte is passed as is.
266
+ `command="${input.command.replace(/"/g, '\\"')}"`,
267
+ ...(input.from ? [`from="${input.from}"`] : []),
268
+ "no-pty",
269
+ "no-port-forwarding",
270
+ "no-agent-forwarding",
271
+ "no-X11-forwarding",
272
+ "no-user-rc",
273
+ ];
274
+ return `${opts.join(",")} ${parsePublicKey(input.key)} ${GATE_KEY_COMMENT_PREFIX}${input.id}`;
275
+ }
276
+ function lineId(line) {
277
+ const m = line.trim().match(/\szswarm-gate:([A-Za-z0-9._-]+)\s*$/);
278
+ return m ? m[1] : null;
279
+ }
280
+ /** Replace this id's gate line, or append it. Other lines are kept byte for byte. */
281
+ export function upsertGateKey(text, id, line) {
282
+ const lines = text.split(/\r?\n/);
283
+ const kept = lines.filter((l) => lineId(l) !== id);
284
+ while (kept.length > 0 && kept[kept.length - 1] === "")
285
+ kept.pop();
286
+ kept.push(line);
287
+ return `${kept.join("\n")}\n`;
288
+ }
289
+ export function removeGateKey(text, id) {
290
+ const kept = text.split(/\r?\n/).filter((l) => lineId(l) !== id);
291
+ while (kept.length > 0 && kept[kept.length - 1] === "")
292
+ kept.pop();
293
+ return kept.length ? `${kept.join("\n")}\n` : "";
294
+ }
295
+ export function listGateKeys(text) {
296
+ return text
297
+ .split(/\r?\n/)
298
+ .map((l) => ({ l, id: lineId(l) }))
299
+ .filter((x) => x.id !== null)
300
+ .map(({ l, id }) => ({
301
+ id,
302
+ from: l.match(/from="([^"]*)"/)?.[1] ?? null,
303
+ keyType: l.match(/\s(ssh-[a-z0-9]+|ecdsa-[a-z0-9-]+|sk-[a-z0-9@.-]+)\s/)?.[1] ?? "",
304
+ }));
305
+ }
306
+ /**
307
+ * authorized_keys for this account. On Windows an administrator's keys live in
308
+ * %ProgramData%\ssh\administrators_authorized_keys when that file exists.
309
+ */
310
+ export function defaultAuthorizedKeysPath(env = process.env, platform = process.platform) {
311
+ if (platform === "win32") {
312
+ const admin = join(env.ProgramData || "C:\\ProgramData", "ssh", "administrators_authorized_keys");
313
+ if (existsSync(admin))
314
+ return admin;
315
+ }
316
+ return join(env.USERPROFILE || env.HOME || "", ".ssh", "authorized_keys");
317
+ }
318
+ export function writeAuthorizedKeys(path, text, platform = process.platform) {
319
+ // Windows sshd checks the file's ACL; a renamed temp file would not keep it.
320
+ if (platform === "win32")
321
+ writeFileSync(path, text);
322
+ else
323
+ writeAtomic(path, text, 0o600);
324
+ }
@@ -31,6 +31,11 @@ export type DispatchDeps = {
31
31
  * the worker host's environment.
32
32
  */
33
33
  viaServe?: boolean;
34
+ /**
35
+ * Set by `zswarm gate <id>`: the request's origin is kept for session and
36
+ * pane, but its host becomes the key-bound id and it is stamped `via gate`.
37
+ */
38
+ gateId?: string;
34
39
  /** MCP cancellation; aborted waits stop instead of running to timeout. */
35
40
  signal?: AbortSignal;
36
41
  /** Process-owned ssh:// LocalForward manager. CLI disposes; MCP reuses. */
package/dist/schema.js CHANGED
@@ -130,6 +130,13 @@ export const PARAMS = [
130
130
  flags: ["--ssh"],
131
131
  description: "route this call over SSH to user@host (or an alias) for the invocation; clears ZSWARM_SERVE; put flags in ZSWARM_SSH_OPTS",
132
132
  },
133
+ {
134
+ name: "gate",
135
+ type: "string",
136
+ flags: ["--gate"],
137
+ ops: ["send"],
138
+ description: "send through a gate on that machine: an ssh alias (or user@host) whose key the receiver allowed with `zswarm gate allow`; one ssh call, send only (ZSWARM_SSH_MODE=gate)",
139
+ },
133
140
  {
134
141
  name: "group",
135
142
  type: "string",
@@ -655,7 +662,7 @@ export function cliUsage() {
655
662
  const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
656
663
  lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
657
664
  }
658
- lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve (host:port, tcp://, or ssh://user@host?servePort=9419) plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. Private raw TCP Tailscale Serve keeps the backend on 127.0.0.1 behind `tailscale serve --tcp=…` (docs/tailscale.md). ssh:// does not start remote serve. Windows default recipe: `zswarm serve --install` (verified readiness).", "Hosts (CLI only, see docs/hosts.md): `zswarm slot`, `zswarm crew`, `zswarm relay`, `zswarm host`; `zswarm <command> --help` for each.", "Doctor: `zswarm doctor --session crew` inspects local, --ssh, and --serve routes without installs, pane changes, or plugin launch. See docs/doctor.md and docs/tailscale.md.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZSWARM_HOST, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_SSH_BIN, ZSWARM_SSH_OPTS, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_TAILSCALE_BIN, ZSWARM_CACHE_TTL_MS", "");
665
+ lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve (host:port, tcp://, or ssh://user@host?servePort=9419) plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. Private raw TCP Tailscale Serve keeps the backend on 127.0.0.1 behind `tailscale serve --tcp=…` (docs/tailscale.md). ssh:// does not start remote serve. Windows default recipe: `zswarm serve --install` (verified readiness).", "Hosts (CLI only, see docs/hosts.md): `zswarm slot`, `zswarm crew`, `zswarm relay`, `zswarm host`, `zswarm gate`; `zswarm <command> --help` for each.", "Gate (docs/gate.md): a send-only ssh key. Receiver: `zswarm gate allow ID --key-file K --session S --pane P`. Sender: `zswarm send --gate DEST --session S --to P --body …`.", "Doctor: `zswarm doctor --session crew` inspects local, --ssh, and --serve routes without installs, pane changes, or plugin launch. See docs/doctor.md and docs/tailscale.md.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZSWARM_HOST, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_SSH_BIN, ZSWARM_SSH_OPTS, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_GATE_CONFIG, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_TAILSCALE_BIN, ZSWARM_CACHE_TTL_MS", "");
659
666
  return lines.join("\n");
660
667
  }
661
668
  /**
package/dist/state.d.ts CHANGED
@@ -16,7 +16,7 @@ export type LogEntry = {
16
16
  /** Sender's pane id, e.g. "terminal_3". */
17
17
  fromPane?: string | null;
18
18
  /** "serve" when this host received the op over serve. */
19
- via?: "serve" | null;
19
+ via?: "serve" | "gate" | null;
20
20
  bytes?: number;
21
21
  ok: boolean;
22
22
  detail?: string | null;
@@ -25,7 +25,7 @@ export declare function assertZellijBinaryPath(path: string): void;
25
25
  * Accept `user@host` or an SSH config alias; put flags in ZSWARM_SSH_OPTS.
26
26
  */
27
27
  export declare function validateSshDestination(raw: string): string;
28
- export declare function validateSshMode(raw: string): "ssh" | "interactive";
28
+ export declare function validateSshMode(raw: string): "ssh" | "interactive" | "gate";
29
29
  /** True when `--version` output is positively Zellij. */
30
30
  export declare function isZellijVersionOutput(stdout: string, stderr?: string): boolean;
31
31
  /**
@@ -96,7 +96,10 @@ export function validateSshMode(raw) {
96
96
  return "ssh";
97
97
  if (mode === "interactive")
98
98
  return "interactive";
99
- throw new ZellijError("bad_ssh_mode", `ZSWARM_SSH_MODE must be "interactive" or "ssh" (or unset); got ${JSON.stringify(raw.trim())}`);
99
+ // `gate`: the whole send goes over one ssh call to a send-only key (ops/gate.ts).
100
+ if (mode === "gate")
101
+ return "gate";
102
+ throw new ZellijError("bad_ssh_mode", `ZSWARM_SSH_MODE must be "interactive", "gate" or "ssh" (or unset); got ${JSON.stringify(raw.trim())}`);
100
103
  }
101
104
  /** Cache of verified `zellij --version` probes keyed by target identity. */
102
105
  const identityCache = new Set();
@@ -34,7 +34,7 @@ export type ZellijClientOptions = {
34
34
  export declare function resetSelfResolutionCache(): void;
35
35
  export type ZellijTransport = {
36
36
  kind: "local" | "ssh";
37
- mode: "local" | "ssh" | "interactive";
37
+ mode: "local" | "ssh" | "interactive" | "gate";
38
38
  host?: string;
39
39
  remoteBin?: string;
40
40
  /** Configured or resolved IPC temp (`auto` until discovery succeeds). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {