nawabari 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -5,10 +5,51 @@ agents. It gives each active session one exclusively owned worktree and one
5
5
  mutable branch. It works without GitHub, `gh`, a network connection, Mottainai,
6
6
  or a particular agent runtime.
7
7
 
8
- Nawabari governs operations routed through Nawabari. It is an authorization and
9
- ownership boundary, not an operating-system or filesystem sandbox: a process
10
- that already has filesystem permissions can still edit another worktree
11
- directly.
8
+ Nawabari governs operations routed through Nawabari. By default it is an
9
+ authorization and ownership boundary, not an operating-system or filesystem
10
+ sandbox: a process that already has filesystem permissions can still edit
11
+ another worktree directly. This default (legacy) mode is unchanged.
12
+
13
+ The opt-in protected session mode defines a Linux-only OS/filesystem/process
14
+ enforcement boundary underneath the existing session/worktree/resource
15
+ authority (`src/domain/sandbox.ts`, contract
16
+ `nawabari.sandbox-execution.v1`). It binds one existing Nawabari session
17
+ resolved through the authoritative registry/guard path to a typed sandbox
18
+ execution request; it does not create a second session identity. A
19
+ machine-readable capability/doctor report distinguishes required Linux
20
+ primitives (bubblewrap, user/mount/PID/IPC/UTS namespaces) from optional
21
+ defense-in-depth primitives (cgroups v2, Landlock, seccomp, capability
22
+ inspection). When protected execution is requested and a required capability
23
+ is unavailable or the platform is unsupported, resolution fails closed and
24
+ never returns a request that claims the legacy unsandboxed path is
25
+ protected. The lower-level contract remains responsible only for capability
26
+ detection and the typed request/result shape; `resolveSandboxExecutionRequest()`
27
+ returns a request without invoking bubblewrap. The managed `session run`
28
+ launcher consumes that request and establishes the protected boundary. Network
29
+ mode is honestly reported as `inherited` (shared with the host), not isolated.
30
+
31
+ `git nawabari session run --session <id> -- <command> [args...]` is the
32
+ managed protected execution entry point. It resolves the existing session
33
+ through the normal guard authority and compiles a fixed bubblewrap argv; the
34
+ command is passed after an argv terminator and is never interpreted by a
35
+ shell. Resolution or launch failure never falls back to the legacy ambient
36
+ filesystem view.
37
+
38
+ The canonical profile starts from a private root, mounts only the owned
39
+ worktree read-write, gives each session private `/tmp`, `/proc`, HOME and
40
+ cache state, and exposes no sibling worktree or control-plane path. A small
41
+ repository-owned `nawabari/sandbox/shared-home` subtree is the only HOME state
42
+ shared between sessions. Selected host user-tool directories (`~/.local/bin`
43
+ and pnpm's user bin when present) are read-only; credentials and the rest of
44
+ the host HOME are not mounted. `/dev`, system certificates/configuration, and
45
+ the detected runtime are explicit read-only/runtime inputs.
46
+
47
+ On standalone Linux the profile uses existing `/usr`, `/bin`, `/lib*` and
48
+ selected `/etc` paths only when present. On NixOS it additionally selects
49
+ `/nix/store`, `/run/current-system`, `/run/wrappers`, and the per-user profile
50
+ when present; no `/usr` layout is assumed. Missing required paths or namespace
51
+ support produces a stable capability/topology error. Network remains
52
+ inherited by design.
12
53
 
13
54
  ## Install
14
55
 
@@ -41,6 +82,13 @@ result-schema versions, identity fields, and stable `failure_codes`. The
41
82
  package version is release metadata; it is not a substitute for the
42
83
  machine-contract identifier.
43
84
 
85
+ The `resource-claims` capability additionally exposes a machine-readable
86
+ `claim_set_replacement` object (`commands`, `atomic: true`,
87
+ `pairing: "adjacent-resource-mode"`, `idempotent_retry: true`,
88
+ `unchanged_on_rejection: true`) describing the atomic multi-claim replacement
89
+ surface documented above, so a caller can discover this contract instead of
90
+ assuming it from the CLI help text.
91
+
44
92
  The supported standalone sequence is:
45
93
 
46
94
  ```text
@@ -182,6 +230,28 @@ git nawabari session update --session "$NAWABARI_SESSION_ID" \
182
230
  git nawabari session release --session "$NAWABARI_SESSION_ID" --json
183
231
  ```
184
232
 
233
+ `session update` (and its `resource update` alias) atomically replaces a
234
+ session's _complete_ claim set in one `updateClaims()` transaction, backed by
235
+ the same repository lock as every other mutation. `--resource`/`--mode` are
236
+ repeatable to submit a multi-resource desired set in a single call; each
237
+ `--resource` must be immediately followed by its own `--mode`, so pairing is
238
+ positional adjacency rather than flag order and repeated resources can never
239
+ be associated with the wrong mode:
240
+
241
+ ```bash
242
+ git nawabari session update --session "$NAWABARI_SESSION_ID" \
243
+ --resource src/a.ts --mode exclusive-write \
244
+ --resource src/b.ts --mode exclusive-write \
245
+ --json
246
+ ```
247
+
248
+ If any requested claim in the set is invalid or conflicts, the whole update
249
+ is rejected and the session's prior claim set is left unchanged; no partial
250
+ or empty intermediate claim state is ever observable. Submitting the same
251
+ complete desired set again is idempotent. A successful replacement's JSON
252
+ exposes the resulting `claims` together with machine-readable `added` and
253
+ `released` claims.
254
+
185
255
  The modes have these normative meanings:
186
256
 
187
257
  - `read`: a non-mutating access declaration. It is not a consistency lease,
@@ -267,6 +337,17 @@ worktree=$(printf '%s' "$created" | jq -r .worktree)
267
337
  git nawabari session close --session "$session_id" --json
268
338
  ```
269
339
 
340
+ For a worker that must receive the protected filesystem/process boundary, use
341
+ the managed launcher instead of starting the worker directly:
342
+
343
+ ```bash
344
+ git nawabari session run --session "$session_id" -- node worker.js
345
+ ```
346
+
347
+ The launcher owns only the child sandbox topology and process attachment. The
348
+ session registry, worktree/branch ownership, resource claims and lifecycle
349
+ remain the authoritative Nawabari domain state.
350
+
270
351
  The orchestrator owns scheduling, prompts, and worker lifetime; Nawabari owns
271
352
  only local session identity, worktree/branch ownership, and safe cleanup. No
272
353
  Mottainai, GitHub, `gh`, network, or agent-runtime dependency is required.
package/dist/cli.d.ts CHANGED
@@ -1,9 +1,14 @@
1
+ import { type DomainResult } from "./domain/errors.js";
1
2
  import { type SessionBackend } from "./domain/session.js";
2
3
  import { type CliIO } from "./presentation.js";
4
+ import { type SandboxCommand, type SandboxExecutionResult, type SandboxProbe, type SandboxRuntimeLayout } from "./domain/sandbox.js";
3
5
  export type CliDependencies = {
4
6
  backend?: SessionBackend;
5
7
  cwd?: string;
6
8
  io?: CliIO;
7
9
  version?: string;
10
+ sandboxRunner?: (request: import("./domain/sandbox.js").SandboxExecutionRequest, command: SandboxCommand) => Promise<DomainResult<SandboxExecutionResult>>;
11
+ sandboxProbe?: SandboxProbe;
12
+ sandboxRuntimeLayout?: SandboxRuntimeLayout;
8
13
  };
9
14
  export declare function runCli(argv: string[], dependencies?: CliDependencies): Promise<number>;
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PAT
6
6
  import { createLocalSessionBackend } from "./domain/session-backend.js";
7
7
  import { defaultCliIO, renderFailure, renderSuccess } from "./presentation.js";
8
8
  import { MACHINE_CONTRACT_ID, MACHINE_CONTRACT_SCHEMA_VERSION, machineContract } from "./contract.js";
9
+ import { resolveSandboxExecutionRequest, runSandboxedCommand, } from "./domain/sandbox.js";
9
10
  const CLI_NAME = "nawabari";
10
11
  const packageMetadata = createRequire(import.meta.url)("../package.json");
11
12
  const VERSION = packageMetadata.version;
@@ -46,6 +47,16 @@ const HELP_COMMANDS = [
46
47
  usage: `${CLI_NAME} session show [--session <id>]`,
47
48
  options: [option("--session", "Select a session instead of the current worktree owner", { value: "<id>" })],
48
49
  },
50
+ {
51
+ name: "session run",
52
+ summary: "Run one command inside the protected session sandbox",
53
+ usage: `${CLI_NAME} session run [--session <id>] -- <command> [args...]`,
54
+ options: [option("--session", "Select the active owned session", { value: "<id>" })],
55
+ notes: [
56
+ "The -- terminator is mandatory. The command is passed as argv without a shell, and protected execution is fail-closed.",
57
+ "session exec is an alias.",
58
+ ],
59
+ },
49
60
  {
50
61
  name: "session list",
51
62
  summary: "List bounded repository session records",
@@ -69,14 +80,25 @@ const HELP_COMMANDS = [
69
80
  },
70
81
  {
71
82
  name: "session update",
72
- summary: "Replace a session's resource claims",
73
- usage: `${CLI_NAME} session update --resource <path-or-glob> --mode <read|write|exclusive-write> [--session <id>]`,
83
+ summary: "Atomically replace a session's complete resource claim set",
84
+ usage: `${CLI_NAME} session update --resource <path-or-glob> --mode <read|write|exclusive-write> [--resource <path-or-glob> --mode <read|write|exclusive-write> ...] [--session <id>]`,
74
85
  options: [
75
- option("--resource", "Repository-relative resource", { value: "<path-or-glob>", required: true }),
76
- option("--mode", "Granted claim mode", { value: "<read|write|exclusive-write>", required: true }),
86
+ option("--resource", "Repository-relative resource; repeatable, each paired with the --mode immediately after it", {
87
+ value: "<path-or-glob>",
88
+ required: true,
89
+ }),
90
+ option("--mode", "Mode for the --resource immediately before it; repeatable", {
91
+ value: "<read|write|exclusive-write>",
92
+ required: true,
93
+ }),
77
94
  option("--session", "Target active session; omitted resolves the current owner", { value: "<id>" }),
78
95
  option("--repository", "Expected repository identity", { value: "<id>" }),
79
96
  ],
97
+ notes: [
98
+ "The desired claim set fully replaces the session's current claims in one updateClaims() transaction; " +
99
+ "on any invalid or conflicting claim the prior set is left unchanged.",
100
+ "Each --resource must be immediately followed by its own --mode; pairing is positional adjacency, not flag order.",
101
+ ],
80
102
  },
81
103
  {
82
104
  name: "session claims",
@@ -101,9 +123,25 @@ const HELP_COMMANDS = [
101
123
  },
102
124
  {
103
125
  name: "resource update",
104
- summary: "Replace a session's resource claims (alias)",
105
- usage: `${CLI_NAME} resource update --resource <path-or-glob> --mode <read|write|exclusive-write>`,
106
- options: [],
126
+ summary: "Atomically replace a session's complete resource claim set (alias)",
127
+ usage: `${CLI_NAME} resource update --resource <path-or-glob> --mode <read|write|exclusive-write> [--resource <path-or-glob> --mode <read|write|exclusive-write> ...] [--session <id>]`,
128
+ options: [
129
+ option("--resource", "Repository-relative resource; repeatable, each paired with the --mode immediately after it", {
130
+ value: "<path-or-glob>",
131
+ required: true,
132
+ }),
133
+ option("--mode", "Mode for the --resource immediately before it; repeatable", {
134
+ value: "<read|write|exclusive-write>",
135
+ required: true,
136
+ }),
137
+ option("--session", "Target active session; omitted resolves the current owner", { value: "<id>" }),
138
+ option("--repository", "Expected repository identity", { value: "<id>" }),
139
+ ],
140
+ notes: [
141
+ "The desired claim set fully replaces the session's current claims in one updateClaims() transaction; " +
142
+ "on any invalid or conflicting claim the prior set is left unchanged.",
143
+ "Each --resource must be immediately followed by its own --mode; pairing is positional adjacency, not flag order.",
144
+ ],
107
145
  },
108
146
  {
109
147
  name: "resource list",
@@ -246,7 +284,9 @@ function helpSpecFor(commandArguments) {
246
284
  return ROOT_HELP_SPEC;
247
285
  let key = commandArguments[0] === "resource"
248
286
  ? `resource ${commandArguments[1] ?? "list"}`
249
- : commandArguments.slice(0, 2).join(" ");
287
+ : commandArguments[0] === "session" && commandArguments[1] === "exec"
288
+ ? "session run"
289
+ : commandArguments.slice(0, 2).join(" ");
250
290
  if (key === "resource claims")
251
291
  key = "resource list";
252
292
  const direct = HELP_COMMANDS.find((spec) => spec.name === key);
@@ -348,7 +388,17 @@ function parseGlobalArguments(argv) {
348
388
  let help = false;
349
389
  let version = false;
350
390
  const commandArguments = [];
391
+ let passthrough = false;
351
392
  for (const argument of argv) {
393
+ if (passthrough) {
394
+ commandArguments.push(argument);
395
+ continue;
396
+ }
397
+ if (argument === "--") {
398
+ passthrough = true;
399
+ commandArguments.push(argument);
400
+ continue;
401
+ }
352
402
  if (argument === "--json") {
353
403
  json = true;
354
404
  }
@@ -498,6 +548,83 @@ function parseOptions(arguments_, allowed) {
498
548
  function noOptions(arguments_) {
499
549
  return parseOptions(arguments_, new Set());
500
550
  }
551
+ /**
552
+ * `update` accepts a complete desired claim set as repeated
553
+ * `--resource <path> --mode <mode>` pairs. Pairing is by strict local
554
+ * adjacency (each `--resource` must be immediately followed by its own
555
+ * `--mode`, with no other option able to intervene) rather than by
556
+ * parallel-array position, so argv order can never associate a resource
557
+ * with the wrong mode.
558
+ */
559
+ function parseClaimReplacementPairs(arguments_) {
560
+ let sessionId = null;
561
+ let repository = null;
562
+ const pairs = [];
563
+ let pendingResource = null;
564
+ for (let index = 0; index < arguments_.length; index += 1) {
565
+ const { name, inlineValue } = optionParts(arguments_[index]);
566
+ if (name !== "--session" && name !== "--repository" && name !== "--resource" && name !== "--mode") {
567
+ return failure(usageError("INVALID_ARGUMENT", `Unknown option: ${name}.`, { option: name }));
568
+ }
569
+ if (pendingResource !== null && name !== "--mode") {
570
+ return failure(usageError("INVALID_ARGUMENT", `${name} cannot appear between --resource and its --mode; only --mode is permitted immediately after --resource.`, { option: name }));
571
+ }
572
+ const value = inlineValue ?? arguments_[index + 1];
573
+ if (value === undefined || value === "" || (inlineValue === null && value.startsWith("-"))) {
574
+ return failure(usageError("MISSING_ARGUMENT", `${name} requires a value.`, { option: name }));
575
+ }
576
+ if (inlineValue === null)
577
+ index += 1;
578
+ if (name === "--session")
579
+ sessionId = value;
580
+ else if (name === "--repository")
581
+ repository = value;
582
+ else if (name === "--resource")
583
+ pendingResource = value;
584
+ else {
585
+ if (pendingResource === null) {
586
+ return failure(usageError("INVALID_ARGUMENT", "--mode must be immediately preceded by its own --resource.", {
587
+ option: "--mode",
588
+ }));
589
+ }
590
+ pairs.push({ resource: pendingResource, mode: value });
591
+ pendingResource = null;
592
+ }
593
+ }
594
+ if (pendingResource !== null) {
595
+ return failure(usageError("MISSING_ARGUMENT", "--resource must be immediately followed by --mode.", { option: "--mode" }));
596
+ }
597
+ if (pairs.length === 0) {
598
+ return failure(usageError("MISSING_ARGUMENT", "--resource requires a value.", { option: "--resource" }));
599
+ }
600
+ return { ok: true, value: { session_id: sessionId, repository, pairs } };
601
+ }
602
+ /**
603
+ * `claim` accepts exactly one `--resource`/`--mode` pair. Reuses the same
604
+ * strict-adjacency scan as `update` so a second `--resource`/`--mode` (in
605
+ * any order) is rejected outright instead of silently overwriting the
606
+ * first pair (last-wins).
607
+ */
608
+ function parseSingleClaimPair(arguments_) {
609
+ const parsed = parseClaimReplacementPairs(arguments_);
610
+ if (!parsed.ok)
611
+ return parsed;
612
+ if (parsed.value.pairs.length > 1) {
613
+ return failure(usageError("INVALID_ARGUMENT", "claim accepts exactly one --resource/--mode pair.", {
614
+ pair_count: parsed.value.pairs.length,
615
+ }));
616
+ }
617
+ const [pair] = parsed.value.pairs;
618
+ return {
619
+ ok: true,
620
+ value: {
621
+ session_id: parsed.value.session_id,
622
+ repository: parsed.value.repository,
623
+ resource: pair.resource,
624
+ mode: pair.mode,
625
+ },
626
+ };
627
+ }
501
628
  function sessionListingOptions(parsed) {
502
629
  const parseInteger = (option, value) => {
503
630
  if (value === null)
@@ -557,25 +684,33 @@ async function executeCommand(commandArguments, dependencies) {
557
684
  if (subcommand === undefined) {
558
685
  return failure(usageError("MISSING_ARGUMENT", "session requires a subcommand."));
559
686
  }
560
- if (subcommand === "claim" || subcommand === "update") {
561
- const parsed = parseOptions(rest, new Set(["--session", "--resource", "--mode", "--repository"]));
687
+ if (subcommand === "claim") {
688
+ const parsed = parseSingleClaimPair(rest);
562
689
  if (!parsed.ok)
563
690
  return parsed;
564
- if (parsed.value.resource === null) {
565
- return failure(usageError("MISSING_ARGUMENT", "--resource requires a value.", { option: "--resource" }));
566
- }
567
- if (parsed.value.mode === null) {
568
- return failure(usageError("MISSING_ARGUMENT", "--mode requires a value.", { option: "--mode" }));
569
- }
570
- const options = {
691
+ if (dependencies.backend.claimResources === undefined)
692
+ return claimCapabilityUnavailable(subcommand);
693
+ const result = await dependencies.backend.claimResources(context, {
571
694
  session_id: parsed.value.session_id,
572
695
  repository: parsed.value.repository,
573
696
  claims: [{ resource: parsed.value.resource, mode: parsed.value.mode }],
574
- };
575
- const operation = subcommand === "claim" ? dependencies.backend.claimResources : dependencies.backend.updateClaims;
576
- if (operation === undefined)
697
+ });
698
+ return result.ok ? { ok: true, value: result.value } : result;
699
+ }
700
+ if (subcommand === "update") {
701
+ const parsed = parseClaimReplacementPairs(rest);
702
+ if (!parsed.ok)
703
+ return parsed;
704
+ if (dependencies.backend.updateClaims === undefined)
577
705
  return claimCapabilityUnavailable(subcommand);
578
- const result = await operation.call(dependencies.backend, context, options);
706
+ const result = await dependencies.backend.updateClaims(context, {
707
+ session_id: parsed.value.session_id,
708
+ repository: parsed.value.repository,
709
+ claims: parsed.value.pairs.map((pair) => ({
710
+ resource: pair.resource,
711
+ mode: pair.mode,
712
+ })),
713
+ });
579
714
  return result.ok ? { ok: true, value: result.value } : result;
580
715
  }
581
716
  if (subcommand === "claims") {
@@ -612,6 +747,28 @@ async function executeCommand(commandArguments, dependencies) {
612
747
  const result = await dependencies.backend.createSession(context, options);
613
748
  return result.ok ? { ok: true, value: result.value } : result;
614
749
  }
750
+ if (subcommand === "run" || subcommand === "exec") {
751
+ const delimiter = rest.indexOf("--");
752
+ if (delimiter === -1) {
753
+ return failure(usageError("MISSING_ARGUMENT", "session run requires a -- terminator before the command."));
754
+ }
755
+ const parsed = parseOptions(rest.slice(0, delimiter), new Set(["--session"]));
756
+ if (!parsed.ok)
757
+ return parsed;
758
+ const command = rest[delimiter + 1];
759
+ if (command === undefined || command.length === 0) {
760
+ return failure(usageError("MISSING_ARGUMENT", "session run requires a command after --."));
761
+ }
762
+ const request = await resolveSandboxExecutionRequest(dependencies.backend, context, { session_id: parsed.value.session_id, enforce: true }, dependencies.sandboxProbe, dependencies.sandboxRuntimeLayout);
763
+ if (!request.ok)
764
+ return request;
765
+ const runner = dependencies.sandboxRunner ?? runSandboxedCommand;
766
+ const result = await runner(request.value, {
767
+ command,
768
+ args: rest.slice(delimiter + 2),
769
+ });
770
+ return result.ok ? { ok: true, value: result.value } : result;
771
+ }
615
772
  if (subcommand === "id" || subcommand === "show" || subcommand === "close") {
616
773
  if (subcommand === "id") {
617
774
  const parsed = noOptions(rest);
@@ -647,23 +804,35 @@ async function executeCommand(commandArguments, dependencies) {
647
804
  }
648
805
  if (command === "resource") {
649
806
  const resourceSubcommand = subcommand ?? "list";
650
- if (resourceSubcommand === "claim" || resourceSubcommand === "update") {
651
- const parsed = parseOptions(rest, new Set(["--session", "--resource", "--mode", "--repository"]));
807
+ if (resourceSubcommand === "claim") {
808
+ const parsed = parseSingleClaimPair(rest);
652
809
  if (!parsed.ok)
653
810
  return parsed;
654
- if (parsed.value.resource === null || parsed.value.mode === null) {
655
- return failure(usageError("MISSING_ARGUMENT", "resource claim requires --resource and --mode."));
656
- }
657
- const operation = resourceSubcommand === "claim" ? dependencies.backend.claimResources : dependencies.backend.updateClaims;
658
- if (operation === undefined)
811
+ if (dependencies.backend.claimResources === undefined)
659
812
  return claimCapabilityUnavailable(resourceSubcommand);
660
- const result = await operation.call(dependencies.backend, context, {
813
+ const result = await dependencies.backend.claimResources(context, {
661
814
  session_id: parsed.value.session_id,
662
815
  repository: parsed.value.repository,
663
816
  claims: [{ resource: parsed.value.resource, mode: parsed.value.mode }],
664
817
  });
665
818
  return result.ok ? { ok: true, value: result.value } : result;
666
819
  }
820
+ if (resourceSubcommand === "update") {
821
+ const parsed = parseClaimReplacementPairs(rest);
822
+ if (!parsed.ok)
823
+ return parsed;
824
+ if (dependencies.backend.updateClaims === undefined)
825
+ return claimCapabilityUnavailable(resourceSubcommand);
826
+ const result = await dependencies.backend.updateClaims(context, {
827
+ session_id: parsed.value.session_id,
828
+ repository: parsed.value.repository,
829
+ claims: parsed.value.pairs.map((pair) => ({
830
+ resource: pair.resource,
831
+ mode: pair.mode,
832
+ })),
833
+ });
834
+ return result.ok ? { ok: true, value: result.value } : result;
835
+ }
667
836
  if (resourceSubcommand === "list" || resourceSubcommand === "claims") {
668
837
  const parsed = parseOptions(rest, new Set(["--session"]));
669
838
  if (!parsed.ok)
@@ -963,7 +1132,9 @@ function emitFailure(mode, command, error, io) {
963
1132
  }
964
1133
  export async function runCli(argv, dependencies = {}) {
965
1134
  const io = dependencies.io ?? defaultCliIO();
966
- const mode = argv.includes("--json") ? "json" : "human";
1135
+ const delimiter = argv.indexOf("--");
1136
+ const globalArguments = delimiter === -1 ? argv : argv.slice(0, delimiter);
1137
+ const mode = globalArguments.includes("--json") ? "json" : "human";
967
1138
  const parsed = parseGlobalArguments(argv);
968
1139
  if (!parsed.ok)
969
1140
  return emitFailure(mode, "cli", parsed.error, io);
@@ -1004,10 +1175,23 @@ export async function runCli(argv, dependencies = {}) {
1004
1175
  const cwd = dependencies.cwd ?? process.cwd();
1005
1176
  try {
1006
1177
  const backend = dependencies.backend ?? createLocalSessionBackend();
1007
- const result = await executeCommand(parsed.value.commandArguments, { backend, cwd });
1178
+ const result = await executeCommand(parsed.value.commandArguments, {
1179
+ backend,
1180
+ cwd,
1181
+ sandboxRunner: dependencies.sandboxRunner,
1182
+ sandboxProbe: dependencies.sandboxProbe,
1183
+ sandboxRuntimeLayout: dependencies.sandboxRuntimeLayout,
1184
+ });
1008
1185
  if (!result.ok)
1009
1186
  return emitFailure(mode, command, result.error, io);
1010
1187
  io.stdout(renderSuccess(mode, command, result.value));
1188
+ if ((command === "session run" || command === "session exec") &&
1189
+ typeof result.value.exit_code === "number" &&
1190
+ result.value.exit_code !== 0) {
1191
+ return result.value.exit_code >= 1 && result.value.exit_code <= 255
1192
+ ? result.value.exit_code
1193
+ : EXIT_CODES.rejected;
1194
+ }
1011
1195
  return EXIT_CODES.success;
1012
1196
  }
1013
1197
  catch {