@serverkgg/bridge 0.1.12 → 0.1.13

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/rules/package.md +2 -1
  3. package/src/bot/botDeploy.test.ts +57 -0
  4. package/src/bot/botEnvironment.test.ts +21 -0
  5. package/src/bot/botEnvironment.ts +10 -0
  6. package/src/bot/botRun.test.ts +296 -0
  7. package/src/bot/botRun.ts +109 -0
  8. package/src/bot/botState.test.ts +34 -1
  9. package/src/bot/botState.ts +1 -0
  10. package/src/bot/botVariables.test.ts +22 -0
  11. package/src/bot/botVariables.ts +3 -0
  12. package/src/bot/detect/detect.test.ts +172 -0
  13. package/src/bot/detect/detect.ts +31 -0
  14. package/src/bot/detect/detectResult.ts +1 -0
  15. package/src/bot/detect/detectTable.test.ts +25 -2
  16. package/src/bot/detect/detectTable.ts +24 -1
  17. package/src/bot/detect/keys.test.ts +7 -0
  18. package/src/bot/detect/keys.ts +3 -0
  19. package/src/bot/detect/serverkJson.test.ts +21 -0
  20. package/src/bot/detect/serverkJson.ts +13 -1
  21. package/src/bot/index.ts +1 -0
  22. package/src/cli/link.ts +3 -3
  23. package/src/cli/main.ts +8 -0
  24. package/src/cli/readme.test.ts +470 -0
  25. package/src/cli/readme.ts +570 -0
  26. package/src/cli/validate.test.ts +41 -1
  27. package/src/cli/validate.ts +25 -0
  28. package/src/context/bridgeContext.ts +1 -0
  29. package/src/modules/bridgeModule.ts +1 -0
  30. package/src/protocol/bridgeManifest.ts +1 -0
  31. package/src/runtime/bridgeContext.test.ts +6 -0
  32. package/src/runtime/bridgeContext.ts +7 -2
  33. package/src/runtime/bridgeExec.test.ts +150 -0
  34. package/src/runtime/bridgeExec.ts +67 -19
  35. package/src/runtime/bridgeLog.ts +9 -0
  36. package/src/runtime/bridgeRedaction.test.ts +28 -2
  37. package/src/runtime/bridgeRedaction.ts +7 -1
  38. package/src/runtime/bridgeRegistry.test.ts +30 -0
  39. package/src/runtime/bridgeRegistry.ts +1 -0
  40. package/src/runtime/bridgeScopedContext.ts +3 -0
  41. package/src/runtime/bridgeSetupHandlers.test.ts +3 -0
  42. package/src/runtime/bridgeSupervisor.ts +2 -6
  43. package/src/runtime/serve.ts +9 -2
  44. package/src/wire/panel.test.ts +32 -0
  45. package/src/wire/panel.ts +1 -0
@@ -13,6 +13,7 @@ export interface BridgeTerminalRuleManifest {
13
13
  export interface BridgeTerminalManifest {
14
14
  commands: BridgeTerminalCommand[];
15
15
  rules: BridgeTerminalRuleManifest[];
16
+ shell: boolean;
16
17
  }
17
18
 
18
19
  export interface BridgeModuleManifest {
@@ -66,6 +66,9 @@ describe("the context's variable maps", () => {
66
66
  supervisor,
67
67
  {
68
68
  onLog: log,
69
+ onPrint() {
70
+ return;
71
+ },
69
72
  onEmit() {
70
73
  return;
71
74
  },
@@ -155,6 +158,9 @@ describe("the context's ports and address", () => {
155
158
  supervisor,
156
159
  {
157
160
  onLog: log,
161
+ onPrint() {
162
+ return;
163
+ },
158
164
  onEmit() {
159
165
  return;
160
166
  },
@@ -1,5 +1,5 @@
1
1
  import type { BridgeContext } from "../context";
2
- import type { BridgeActivity, BridgeValues } from "../core";
2
+ import { type BridgeActivity, BridgeStream, type BridgeValues } from "../core";
3
3
  import type { BridgeConfig, BridgeSetupRuntime } from "../protocol";
4
4
  import { stripAnsi } from "../utils";
5
5
  import { createBridgeCodecs } from "./bridgeCodec";
@@ -8,7 +8,7 @@ import { createBridgeEgress } from "./bridgeEgress";
8
8
  import { createBridgeExec } from "./bridgeExec";
9
9
  import { createBridgeFiles } from "./bridgeFiles";
10
10
  import { type BridgeLiveConfig, createBridgeLiveConfig } from "./bridgeLiveConfig";
11
- import type { BridgeLeveledLogger } from "./bridgeLog";
11
+ import type { BridgeLeveledLogger, BridgePrinter } from "./bridgeLog";
12
12
  import { createBridgeNet } from "./bridgeNet";
13
13
  import { createBridgeProbes } from "./bridgeProbe";
14
14
  import { createBridgeRcons } from "./bridgeRcon";
@@ -16,6 +16,7 @@ import type { BridgeSupervisor } from "./bridgeSupervisor";
16
16
 
17
17
  export interface ContextHooks {
18
18
  onLog: BridgeLeveledLogger;
19
+ onPrint: BridgePrinter;
19
20
  onEmit: (event: string, payload?: BridgeValues) => void;
20
21
  onSetup: (runtime: BridgeSetupRuntime | null) => void;
21
22
  onActivity: (entry: BridgeActivity) => void;
@@ -178,5 +179,9 @@ export const createContext = (
178
179
  },
179
180
  },
180
181
  ),
182
+
183
+ print(line, stream = BridgeStream.Stdout) {
184
+ hooks.onPrint(line, stream);
185
+ },
181
186
  };
182
187
  };
@@ -0,0 +1,150 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { BridgeStream } from "../core";
6
+ import { createBridgeExec } from "./bridgeExec";
7
+
8
+ const SHELL = "/bin/sh";
9
+
10
+ const GROUP_GONE_WAIT_MS = 2000;
11
+
12
+ const GROUP_POLL_MS = 100;
13
+
14
+ const TIMING_TEST_TIMEOUT_MS = 20_000;
15
+
16
+ const groupAlive = (group: number) => {
17
+ try {
18
+ process.kill(-group, 0);
19
+
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+
26
+ const groupGone = async (group: number) => {
27
+ for (let waited = 0; waited < GROUP_GONE_WAIT_MS; waited += GROUP_POLL_MS) {
28
+ if (!groupAlive(group)) {
29
+ return true;
30
+ }
31
+
32
+ await Bun.sleep(GROUP_POLL_MS);
33
+ }
34
+
35
+ return !groupAlive(group);
36
+ };
37
+
38
+ describe("createBridgeExec", () => {
39
+ let directory = "";
40
+
41
+ const execOf = () => {
42
+ return createBridgeExec({
43
+ cwd: directory,
44
+ uid: process.getuid?.() ?? 0,
45
+ gid: process.getgid?.() ?? 0,
46
+ });
47
+ };
48
+
49
+ beforeEach(async () => {
50
+ directory = await mkdtemp(join(tmpdir(), "serverk-exec-"));
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await rm(directory, {
55
+ force: true,
56
+ recursive: true,
57
+ });
58
+ });
59
+
60
+ it("returns the output of a child that writes and exits", async () => {
61
+ const lines: string[] = [];
62
+
63
+ const result = await execOf()(
64
+ [
65
+ SHELL,
66
+ "-c",
67
+ "echo hello; echo oops >&2",
68
+ ],
69
+ {
70
+ onOutput(line: string, stream: BridgeStream) {
71
+ lines.push(`${stream} ${line}`);
72
+ },
73
+ },
74
+ );
75
+
76
+ expect(result).toEqual({
77
+ stdout: "hello\n",
78
+ stderr: "oops\n",
79
+ code: 0,
80
+ });
81
+ expect(lines.toSorted()).toEqual([
82
+ "stderr oops",
83
+ "stdout hello",
84
+ ]);
85
+ });
86
+
87
+ it(
88
+ "kills every process of the command on the deadline and returns at once",
89
+ async () => {
90
+ const startedAt = performance.now();
91
+
92
+ const result = await execOf()(
93
+ [
94
+ SHELL,
95
+ "-c",
96
+ "sleep 30 & sleep 30",
97
+ ],
98
+ {
99
+ timeoutMs: 300,
100
+ },
101
+ );
102
+
103
+ expect(result.code).not.toBe(0);
104
+ expect(performance.now() - startedAt).toBeLessThan(2500);
105
+ },
106
+ TIMING_TEST_TIMEOUT_MS,
107
+ );
108
+
109
+ it(
110
+ "kills what a command left running in its group once it returns",
111
+ async () => {
112
+ const startedAt = performance.now();
113
+
114
+ const result = await execOf()([
115
+ SHELL,
116
+ "-c",
117
+ "echo $$; sleep 30 &",
118
+ ]);
119
+
120
+ const group = Number(result.stdout.trim());
121
+
122
+ expect(result.code).toBe(0);
123
+ expect(Number.isSafeInteger(group) && group > 0).toBe(true);
124
+ expect(performance.now() - startedAt).toBeLessThan(5000);
125
+ expect(await groupGone(group)).toBe(true);
126
+ },
127
+ TIMING_TEST_TIMEOUT_MS,
128
+ );
129
+
130
+ it.skipIf(!Bun.which("setsid"))(
131
+ "returns after a short grace when a process that left the group still holds the output open",
132
+ async () => {
133
+ const startedAt = performance.now();
134
+
135
+ const result = await execOf()([
136
+ SHELL,
137
+ "-c",
138
+ "echo started; setsid sleep 6 &",
139
+ ]);
140
+
141
+ expect(result).toEqual({
142
+ stdout: "started\n",
143
+ stderr: "",
144
+ code: 0,
145
+ });
146
+ expect(performance.now() - startedAt).toBeLessThan(5000);
147
+ },
148
+ TIMING_TEST_TIMEOUT_MS,
149
+ );
150
+ });
@@ -1,6 +1,7 @@
1
1
  import type { BridgeExecOptions, BridgeExecResult } from "../context";
2
2
  import { BridgeStream } from "../core";
3
3
  import { settleUid, unprivilegedArgv } from "./bridgePrivilege";
4
+ import { readProcessGroup, signalGroup } from "./bridgeSignal";
4
5
 
5
6
  const DEFAULT_EXEC_TIMEOUT_MS = 900_000;
6
7
 
@@ -8,6 +9,8 @@ const OUTPUT_LIMIT = 256 * 1024;
8
9
 
9
10
  const EXIT_GRACE_MS = 3000;
10
11
 
12
+ const DRAIN_GRACE_MS = 3000;
13
+
11
14
  const FALLBACK_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
12
15
 
13
16
  const inheritedPath = () => {
@@ -45,8 +48,10 @@ const capped = (text: string) => {
45
48
  const drain = async (
46
49
  stream: ReadableStream<Uint8Array>,
47
50
  report: ((line: string) => void) | undefined,
51
+ signal: AbortSignal,
48
52
  ): Promise<string> => {
49
53
  const decoder = new TextDecoder();
54
+ const reader = stream.getReader();
50
55
 
51
56
  let collected = "";
52
57
  let pending = "";
@@ -59,28 +64,46 @@ const drain = async (
59
64
  }
60
65
  };
61
66
 
62
- for await (const chunk of stream) {
63
- const text = decoder.decode(chunk, {
64
- stream: true,
65
- });
67
+ const cancel = () => {
68
+ void reader.cancel().catch(() => undefined);
69
+ };
66
70
 
67
- if (collected.length < OUTPUT_LIMIT) {
68
- collected += text;
69
- }
71
+ signal.addEventListener("abort", cancel, {
72
+ once: true,
73
+ });
70
74
 
71
- if (!report) {
72
- continue;
73
- }
75
+ try {
76
+ for (;;) {
77
+ const chunk = await reader.read();
78
+
79
+ if (chunk.done) {
80
+ break;
81
+ }
82
+
83
+ const text = decoder.decode(chunk.value, {
84
+ stream: true,
85
+ });
86
+
87
+ if (collected.length < OUTPUT_LIMIT) {
88
+ collected += text;
89
+ }
90
+
91
+ if (!report) {
92
+ continue;
93
+ }
74
94
 
75
- pending += text;
95
+ pending += text;
76
96
 
77
- const lines = pending.split("\n");
97
+ const lines = pending.split("\n");
78
98
 
79
- pending = lines.pop() ?? "";
99
+ pending = lines.pop() ?? "";
80
100
 
81
- for (const line of lines) {
82
- emit(line);
101
+ for (const line of lines) {
102
+ emit(line);
103
+ }
83
104
  }
105
+ } finally {
106
+ signal.removeEventListener("abort", cancel);
84
107
  }
85
108
 
86
109
  emit(pending);
@@ -107,6 +130,7 @@ export const createBridgeExec = (target: BridgeExecTarget) => {
107
130
  HOME: target.cwd,
108
131
  ...environment,
109
132
  },
133
+ detached: true,
110
134
  stderr: "pipe",
111
135
  stdin: "ignore",
112
136
  stdout: "pipe",
@@ -131,15 +155,33 @@ export const createBridgeExec = (target: BridgeExecTarget) => {
131
155
  }
132
156
  }
133
157
 
158
+ const group = await readProcessGroup(child.pid);
159
+
134
160
  const timer = setTimeout(() => {
135
- child.kill("SIGKILL");
161
+ if (!signalGroup(child.pid, group, "SIGKILL")) {
162
+ child.kill("SIGKILL");
163
+ }
136
164
  }, timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS);
137
165
 
166
+ const drained = new AbortController();
167
+
168
+ let grace: ReturnType<typeof setTimeout> | null = null;
169
+
170
+ const exited = async () => {
171
+ const code = await child.exited;
172
+
173
+ grace = setTimeout(() => {
174
+ drained.abort();
175
+ }, DRAIN_GRACE_MS);
176
+
177
+ return code;
178
+ };
179
+
138
180
  try {
139
181
  const [stdout, stderr, code] = await Promise.all([
140
- drain(child.stdout, onOutput && ((line) => onOutput(line, BridgeStream.Stdout))),
141
- drain(child.stderr, onOutput && ((line) => onOutput(line, BridgeStream.Stderr))),
142
- child.exited,
182
+ drain(child.stdout, onOutput && ((line) => onOutput(line, BridgeStream.Stdout)), drained.signal),
183
+ drain(child.stderr, onOutput && ((line) => onOutput(line, BridgeStream.Stderr)), drained.signal),
184
+ exited(),
143
185
  ]);
144
186
 
145
187
  return {
@@ -149,6 +191,12 @@ export const createBridgeExec = (target: BridgeExecTarget) => {
149
191
  };
150
192
  } finally {
151
193
  clearTimeout(timer);
194
+
195
+ if (grace !== null) {
196
+ clearTimeout(grace);
197
+ }
198
+
199
+ signalGroup(child.pid, group, "SIGKILL");
152
200
  }
153
201
  };
154
202
  };
@@ -1,8 +1,11 @@
1
+ import { BridgeStream } from "../core";
1
2
  import { BRIDGE_LOG_SOURCE } from "../protocol/bridgeLine";
2
3
  import { BridgeTerminalLevel } from "../terminal";
3
4
 
4
5
  export type BridgeLogger = (message: string, fields?: Record<string, unknown>) => void;
5
6
 
7
+ export type BridgePrinter = (line: string, stream: BridgeStream) => void;
8
+
6
9
  export interface BridgeLeveledLogger extends BridgeLogger {
7
10
  warn: BridgeLogger;
8
11
  error: BridgeLogger;
@@ -33,6 +36,12 @@ export const log: BridgeLeveledLogger = Object.assign(
33
36
  },
34
37
  );
35
38
 
39
+ export const print: BridgePrinter = (line, stream) => {
40
+ const target = stream === BridgeStream.Stderr ? process.stderr : process.stdout;
41
+
42
+ target.write(`${line}\n`);
43
+ };
44
+
36
45
  export const errorText = (error: unknown) => {
37
46
  return error instanceof Error ? error.message : String(error);
38
47
  };
@@ -2,13 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { BridgeKind } from "../core";
5
+ import { BridgeKind, BridgeStream } from "../core";
6
6
  import type { BridgeDriver } from "../modules";
7
7
  import { BridgeControl, BridgeFormTarget, BridgeLayout, type BridgeTab } from "../panel";
8
8
  import type { BridgeConfig } from "../protocol";
9
9
  import { createBridgeLiveConfig } from "./bridgeLiveConfig";
10
10
  import type { BridgeLeveledLogger } from "./bridgeLog";
11
- import { createBridgeRedaction, REDACTED, redactLocalized, redactLogger, secretFieldKeys } from "./bridgeRedaction";
11
+ import {
12
+ createBridgeRedaction,
13
+ REDACTED,
14
+ redactLocalized,
15
+ redactLogger,
16
+ redactPrinter,
17
+ secretFieldKeys,
18
+ } from "./bridgeRedaction";
12
19
 
13
20
  const text = (value: string) => ({
14
21
  ar: value,
@@ -309,6 +316,25 @@ describe("redacting what the bridge reports", () => {
309
316
  },
310
317
  ]);
311
318
  });
319
+
320
+ it("covers a raw console line and keeps its stream", () => {
321
+ const lines: unknown[] = [];
322
+ const printer = redactPrinter((line, stream) => {
323
+ lines.push({
324
+ line,
325
+ stream,
326
+ });
327
+ }, redact);
328
+
329
+ printer(`token=${TOKEN}`, BridgeStream.Stderr);
330
+
331
+ expect(lines).toEqual([
332
+ {
333
+ line: `token=${REDACTED}`,
334
+ stream: BridgeStream.Stderr,
335
+ },
336
+ ]);
337
+ });
312
338
  });
313
339
 
314
340
  describe("redaction over the live config", () => {
@@ -2,7 +2,7 @@ import { BridgeSetupPromptKind, type LocalizedText } from "../core";
2
2
  import type { BridgeDriver } from "../modules";
3
3
  import { BridgeControl, BridgeLayout } from "../panel";
4
4
  import type { BridgeSetupAction, BridgeSetupPrompt, BridgeSetupRuntime } from "../protocol";
5
- import type { BridgeLeveledLogger, BridgeLogger } from "./bridgeLog";
5
+ import type { BridgeLeveledLogger, BridgeLogger, BridgePrinter } from "./bridgeLog";
6
6
 
7
7
  export const REDACTED = "••••";
8
8
 
@@ -212,3 +212,9 @@ export const redactLogger = (logger: BridgeLeveledLogger, redact: BridgeRedact):
212
212
  error: redactingWriter(logger.error, redact),
213
213
  });
214
214
  };
215
+
216
+ export const redactPrinter = (printer: BridgePrinter, redact: BridgeRedact): BridgePrinter => {
217
+ return (line, stream) => {
218
+ printer(redact(line), stream);
219
+ };
220
+ };
@@ -91,6 +91,9 @@ const harnessFor = (driver: BridgeDriver, variables: Record<string, string> = {}
91
91
 
92
92
  const context = createContext(settings, supervisor, {
93
93
  onLog: log,
94
+ onPrint() {
95
+ return;
96
+ },
94
97
  onEmit(event, payload) {
95
98
  ring.emit(event, payload);
96
99
  },
@@ -773,6 +776,33 @@ describe("the setup singleton", () => {
773
776
  });
774
777
  });
775
778
 
779
+ describe("the terminal block", () => {
780
+ const terminalDriver = (shell?: boolean): BridgeDriver => ({
781
+ ...baseDriver(),
782
+ terminal: {
783
+ kind: BridgeKind.Terminal,
784
+ commands: [],
785
+ ...(shell === undefined
786
+ ? {}
787
+ : {
788
+ shell,
789
+ }),
790
+ },
791
+ });
792
+
793
+ it("advertises a terminal that runs its input as a shell command", () => {
794
+ expect(registryFor(terminalDriver(true)).manifest().terminal).toEqual({
795
+ commands: [],
796
+ rules: [],
797
+ shell: true,
798
+ });
799
+ });
800
+
801
+ it("advertises a terminal that declares no shell flag as one that writes to the process", () => {
802
+ expect(registryFor(terminalDriver()).manifest().terminal?.shell).toBe(false);
803
+ });
804
+ });
805
+
776
806
  describe("protected action dispatch", () => {
777
807
  it("advertises protection and refuses old callers before touching files", async () => {
778
808
  let calls = 0;
@@ -112,6 +112,7 @@ const terminalManifestOf = (module: BridgeTerminalModule | undefined): BridgeTer
112
112
  stream: rule.stream,
113
113
  };
114
114
  }),
115
+ shell: module.shell ?? false,
115
116
  };
116
117
  };
117
118
 
@@ -51,5 +51,8 @@ export const scopedContext = (
51
51
  return context.restart();
52
52
  },
53
53
  log: context.log,
54
+ print(line, stream) {
55
+ context.print(line, stream);
56
+ },
54
57
  };
55
58
  };
@@ -45,6 +45,9 @@ const contextOf = (): BridgeContext => {
45
45
 
46
46
  return createContext(settings, supervisor, {
47
47
  onLog: log,
48
+ onPrint() {
49
+ return;
50
+ },
48
51
  onEmit() {
49
52
  return;
50
53
  },
@@ -6,7 +6,7 @@ import { BRIDGE_CRASH_LINE_LIMIT, BridgeCrashPhase, type BridgeCrashRecord } fro
6
6
  import { stripAnsi } from "../utils";
7
7
  import { createLineSpacing } from "./bridgeLineSpacing";
8
8
  import type { BridgeLeveledLogger } from "./bridgeLog";
9
- import { errorText } from "./bridgeLog";
9
+ import { errorText, print } from "./bridgeLog";
10
10
  import { settleUid, unprivilegedArgv } from "./bridgePrivilege";
11
11
  import { readProcessGroup, signalGroup } from "./bridgeSignal";
12
12
 
@@ -287,11 +287,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
287
287
  }
288
288
  : raw;
289
289
 
290
- if (entry.stream === BridgeStream.Stderr) {
291
- process.stderr.write(`${entry.line}\n`);
292
- } else {
293
- process.stdout.write(`${entry.line}\n`);
294
- }
290
+ print(entry.line, entry.stream);
295
291
 
296
292
  tail.push(entry);
297
293
 
@@ -19,9 +19,15 @@ import { validateDriver } from "./bridgeDriver";
19
19
  import { createEventRing } from "./bridgeEvents";
20
20
  import { decideInstall, recountHold } from "./bridgeInstall";
21
21
  import { createBridgeLiveConfig } from "./bridgeLiveConfig";
22
- import { errorDetail, errorText, log as stdoutLog } from "./bridgeLog";
22
+ import { errorDetail, errorText, print, log as stdoutLog } from "./bridgeLog";
23
23
  import { pendingResult, rejectedPending, unsupportedPending } from "./bridgePending";
24
- import { createBridgeRedaction, redactLocalized, redactLogger, secretFieldKeys } from "./bridgeRedaction";
24
+ import {
25
+ createBridgeRedaction,
26
+ redactLocalized,
27
+ redactLogger,
28
+ redactPrinter,
29
+ secretFieldKeys,
30
+ } from "./bridgeRedaction";
25
31
  import { createRegistry } from "./bridgeRegistry";
26
32
  import { createServer } from "./bridgeServer";
27
33
  import { setupSummaryOf } from "./bridgeSetup";
@@ -182,6 +188,7 @@ export const serve = async (driver: BridgeDriver) => {
182
188
  supervisor,
183
189
  {
184
190
  onLog: log,
191
+ onPrint: redactPrinter(print, redact),
185
192
  onEmit(event, payload) {
186
193
  ring.emit(event, payload);
187
194
  },
@@ -13,6 +13,7 @@ import {
13
13
  BridgePlace,
14
14
  type BridgeSetupManifest,
15
15
  BridgeSetupStepKind,
16
+ type BridgeTerminalManifest,
16
17
  BridgeUploadMode,
17
18
  } from "@serverkgg/bridge/protocol";
18
19
  import { bridgeAvailabilitySchema, bridgeCellsSchema, bridgeManifestSchema } from "./panel";
@@ -783,3 +784,34 @@ describe("the availability schema", () => {
783
784
  expect(parsed.modules[0]?.actions).toContain(BridgeAction.Availability);
784
785
  });
785
786
  });
787
+
788
+ const terminalManifest = (shell: boolean): BridgeManifest => ({
789
+ version: "1.0.0",
790
+ panel: null,
791
+ terminal: {
792
+ commands: [],
793
+ rules: [],
794
+ shell,
795
+ },
796
+ setup: null,
797
+ modules: [],
798
+ events: [],
799
+ });
800
+
801
+ describe("the terminal mirror", () => {
802
+ it("carries the shell flag through unchanged", () => {
803
+ expect(bridgeManifestSchema.parse(terminalManifest(true)).terminal?.shell).toBe(true);
804
+ });
805
+
806
+ it("defaults a terminal from an older bridge that carries no shell flag to false", () => {
807
+ const older = terminalManifest(true);
808
+
809
+ delete (older.terminal as Partial<BridgeTerminalManifest>).shell;
810
+
811
+ expect(bridgeManifestSchema.parse(older).terminal).toEqual({
812
+ commands: [],
813
+ rules: [],
814
+ shell: false,
815
+ });
816
+ });
817
+ });
package/src/wire/panel.ts CHANGED
@@ -318,6 +318,7 @@ export const bridgeTerminalRuleSchema: z.ZodType<BridgeTerminalRuleManifest> = z
318
318
  export const bridgeTerminalManifestSchema: z.ZodType<BridgeTerminalManifest> = z.object({
319
319
  commands: z.array(bridgeTerminalCommandSchema).max(128),
320
320
  rules: z.array(bridgeTerminalRuleSchema).max(64),
321
+ shell: z.boolean().default(false),
321
322
  });
322
323
 
323
324
  const setupStepBase = {