@zswarm/core 0.6.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
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { ZellijError } from "../errors.js";
3
- import { createStateStore } from "../state.js";
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";
@@ -151,6 +152,10 @@ async function relay(argv) {
151
152
  if (flags["--json"] === true)
152
153
  return json(() => ({ leases }));
153
154
  process.stdout.write(`${leaseTable(leases)}\n`);
155
+ if (leases.length === 0) {
156
+ // A tool that runs zswarm with its own ZSWARM_STATE_DIR keeps its leases there.
157
+ process.stderr.write(`no live leases in ${defaultStateDir()}; set ZSWARM_STATE_DIR to read another state directory\n`);
158
+ }
154
159
  return 0;
155
160
  }
156
161
  const cancelId = str(flags["--cancel"]);
@@ -284,6 +289,10 @@ const COMMANDS = {
284
289
  ].join("\n"),
285
290
  run: host,
286
291
  },
292
+ gate: {
293
+ usage: GATE_USAGE,
294
+ run: (argv) => gateCommand(argv, (a) => parseHostArgs(a)),
295
+ },
287
296
  };
288
297
  export const HOST_COMMANDS = Object.keys(COMMANDS);
289
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
+ }
@@ -46,6 +46,9 @@ export type RelayConfig = {
46
46
  to: string;
47
47
  done: string;
48
48
  replyTo: string;
49
+ /** The reply pane's unique title when `replyTo` was a pane id: delivery tries
50
+ * the name first, because ids shift when panes are restarted or renumbered. */
51
+ replyToName?: string;
49
52
  replySession?: string;
50
53
  from: string;
51
54
  /** The target harness (`--harness`); sent with the body and the reply. */
@@ -112,6 +115,10 @@ export declare function fillMessage(template: string, fields: Record<string, str
112
115
  * the verdict and keeps waiting — the done line is the real proof of delivery.
113
116
  */
114
117
  export declare function softSendVerdict(res: OpsResult): boolean;
118
+ /** The unique title of the reply pane when `replyTo` is a pane id (`terminal_14`, `14`),
119
+ * else undefined: an untitled pane (default shell title) or a title shared by two panes
120
+ * keeps delivery by id. */
121
+ export declare function replyPaneName(cfg: Pick<RelayConfig, "replyTo" | "replySession">, env: NodeJS.ProcessEnv, call: NonNullable<RelayDeps["call"]>): Promise<string | undefined>;
115
122
  /** Paste `body` into the reply pane, retrying with backoff; false leaves it in undelivered/. */
116
123
  export declare function deliverMessage(cfg: RelayConfig, body: string, dir: string, deps?: RelayDeps): Promise<boolean>;
117
124
  /** The waiting loop; returns the final status and the message it delivered or left behind. */
@@ -111,6 +111,28 @@ export function softSendVerdict(res) {
111
111
  const submitted = res.data?.submitted;
112
112
  return submitted !== true;
113
113
  }
114
+ /** The unique title of the reply pane when `replyTo` is a pane id (`terminal_14`, `14`),
115
+ * else undefined: an untitled pane (default shell title) or a title shared by two panes
116
+ * keeps delivery by id. */
117
+ export async function replyPaneName(cfg, env, call) {
118
+ const ref = cfg.replyTo.trim();
119
+ if (!/^(terminal_)?\d+$/i.test(ref))
120
+ return undefined;
121
+ const args = { op: "list" };
122
+ if (cfg.replySession)
123
+ args.session = cfg.replySession;
124
+ const res = await call(args, localEnv(env)).catch(() => null);
125
+ if (!res?.ok)
126
+ return undefined;
127
+ const panes = (res.data.panes ?? [])
128
+ .filter((p) => !p.isPlugin);
129
+ const id = `terminal_${ref.replace(/^terminal_/i, "")}`;
130
+ const title = panes.find((p) => p.id === id)?.title?.trim();
131
+ if (!title || /^(Pane #\d+|-?(zsh|bash|fish|sh|pwsh|powershell))$/i.test(title))
132
+ return undefined;
133
+ const same = panes.filter((p) => (p.title ?? "").trim().toLowerCase() === title.toLowerCase());
134
+ return same.length === 1 ? title : undefined;
135
+ }
114
136
  /** Paste `body` into the reply pane, retrying with backoff; false leaves it in undelivered/. */
115
137
  export async function deliverMessage(cfg, body, dir, deps = {}) {
116
138
  const env = deps.env ?? process.env;
@@ -125,13 +147,20 @@ export async function deliverMessage(cfg, body, dir, deps = {}) {
125
147
  await sleep(delay * 1000);
126
148
  // Generous Zellij budgets: this runs exactly when the machine is busiest.
127
149
  const args = {
128
- op: "send", to: cfg.replyTo, body, from: cfg.from, fresh: true, settleMs: 800, observeMs: 30_000, timeoutMs: 60_000,
150
+ op: "send", to: cfg.replyToName ?? cfg.replyTo, body, from: cfg.from, fresh: true, settleMs: 800, observeMs: 30_000,
151
+ timeoutMs: 60_000,
129
152
  };
130
153
  if (cfg.replySession)
131
154
  args.session = cfg.replySession;
132
155
  if (cfg.harness)
133
156
  args.harness = cfg.harness;
134
- const res = await call(args, localEnv(env)).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
157
+ const send = (a) => call(a, localEnv(env)).catch((err) => ({ ok: false, error: { code: "failed", message: String(err) } }));
158
+ let res = await send(args);
159
+ if (!res.ok && cfg.replyToName && (res.error.code === "peer_not_found" || res.error.code === "peer_ambiguous")) {
160
+ // The named pane is gone or now ambiguous: the id it had at start is the last resort.
161
+ log(`message attempt ${i + 1}: pane "${cfg.replyToName}" ${res.error.code}; trying ${cfg.replyTo}`);
162
+ res = await send({ ...args, to: cfg.replyTo });
163
+ }
135
164
  if (res.ok) {
136
165
  const submitted = String(res.data?.submitted ?? "unknown");
137
166
  recordDelivery(dir, submitted, now());
@@ -430,6 +459,7 @@ export async function startRelay(input) {
430
459
  };
431
460
  // Read the body before the relay directory (and its token copy) exists.
432
461
  const body = input.bodyFile ? readFileSync(input.bodyFile, "utf8") : undefined;
462
+ cfg.replyToName = await replyPaneName(cfg, env, input.deps?.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e })));
433
463
  const dir = prepareRelay(cfg, { check: input.check, message: input.message }, env);
434
464
  const store = input.store ?? createStateStore({ env });
435
465
  const leaseTarget = { serve, session: cfg.session, pane: cfg.to };
package/dist/index.d.ts CHANGED
@@ -35,10 +35,12 @@ 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";
41
43
  export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, layoutPaneNames, parseEnvFile, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
42
- export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayAdminDeps, type RelayCancelResult, type RelayConfig, type RelayDeps, type RelayJob, type RelayJobRunner, type RelayRow, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
44
+ export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, replyPaneName, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayAdminDeps, type RelayCancelResult, type RelayConfig, type RelayDeps, type RelayJob, type RelayJobRunner, type RelayRow, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
43
45
  export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, type HostCheck, type HostDoctorInput, type HostInstallInput, type SshRunner, } from "./host/host.js";
44
46
  export { isSelfPane } from "./ops/guards.js";
package/dist/index.js CHANGED
@@ -33,10 +33,12 @@ 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";
39
41
  export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, layoutPaneNames, parseEnvFile, } from "./host/crew.js";
40
- export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
42
+ export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, replyPaneName, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
41
43
  export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, } from "./host/host.js";
42
44
  export { isSelfPane } from "./ops/guards.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.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {