@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 +1 -1
- package/dist/host/cli.js +10 -1
- package/dist/host/gate.d.ts +25 -0
- package/dist/host/gate.js +168 -0
- package/dist/host/relay.d.ts +7 -0
- package/dist/host/relay.js +32 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/ops/delivery.d.ts +5 -2
- package/dist/ops/delivery.js +2 -2
- package/dist/ops/dispatch.js +31 -5
- package/dist/ops/gate.d.ts +101 -0
- package/dist/ops/gate.js +324 -0
- package/dist/ops/types.d.ts +5 -0
- package/dist/schema.js +8 -1
- package/dist/state.d.ts +1 -1
- package/dist/zellij/binary.d.ts +1 -1
- package/dist/zellij/binary.js +4 -1
- package/dist/zellij/client.d.ts +1 -1
- package/package.json +1 -1
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
|
+
}
|
package/dist/host/relay.d.ts
CHANGED
|
@@ -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. */
|
package/dist/host/relay.js
CHANGED
|
@@ -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,
|
|
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
|
|
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";
|
package/dist/ops/delivery.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
package/dist/ops/delivery.js
CHANGED
|
@@ -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=
|
|
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"
|
|
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. */
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -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
|
-
:
|
|
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 {};
|
package/dist/ops/gate.js
ADDED
|
@@ -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
|
+
}
|
package/dist/ops/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/zellij/binary.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/zellij/binary.js
CHANGED
|
@@ -96,7 +96,10 @@ export function validateSshMode(raw) {
|
|
|
96
96
|
return "ssh";
|
|
97
97
|
if (mode === "interactive")
|
|
98
98
|
return "interactive";
|
|
99
|
-
|
|
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();
|
package/dist/zellij/client.d.ts
CHANGED
|
@@ -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). */
|