main
  1{
  2  pkgs,
  3  jail,
  4}: let
  5  inherit (pkgs) lib;
  6
  7  commonPkgs = with pkgs; [
  8    bashInteractive
  9
 10    git
 11    jujutsu
 12
 13    curl
 14    wget
 15
 16    findutils
 17    gnugrep
 18    gawkInteractive
 19    jq
 20    ripgrep
 21    which
 22
 23    gnused
 24    gnutar
 25    gzip
 26    unzip
 27
 28    diffutils
 29    just
 30    ps
 31
 32    python3
 33
 34    bash-language-server
 35    nixd
 36    ty
 37  ];
 38
 39  gitExe = lib.getExe pkgs.git;
 40  sedExe = lib.getExe pkgs.gnused;
 41  direnvExe = lib.getExe pkgs.direnv;
 42  jqExe = lib.getExe pkgs.jq;
 43
 44  # Minimal toolchain to evaluate `.envrc` file
 45  devshellPath = lib.makeBinPath (with pkgs; [
 46    bashInteractive
 47    coreutils
 48    direnv
 49    findutils
 50    git
 51    gnugrep
 52    gnused
 53    jq
 54    nix
 55  ]);
 56
 57  commonJailOptions = with jail.combinators; [
 58    network
 59    time-zone
 60    no-new-session
 61  ];
 62
 63  nixDaemonAccess = {
 64    readwriteDirs = [
 65      "/nix/var/nix/daemon-socket"
 66    ];
 67    readonlyDirs = [
 68      "/nix"
 69      "/etc/nix/nix.conf"
 70      "/etc/nix/registry.json"
 71    ];
 72    pkgs = [pkgs.nix];
 73  };
 74
 75  openUrls = with jail.combinators;
 76    compose [
 77      (add-pkg-deps [pkgs.xdg-utils])
 78      (set-env "BROWSER" "browserchannel")
 79      (jail-to-host-channel "browserchannel" ''
 80        case "$1" in
 81          https://*|http://*) ''${BROWSER:-xdg-open} "$1" ;;
 82        esac
 83      '')
 84    ];
 85
 86  # For git worktree or jj workspace
 87  workspaceResolver = jail.combinators.add-runtime ''
 88    _ixBindSharedVcs() {
 89      local git_common gitdir_ref gitdir jj_ref jj_store
 90
 91      git_common="$(${gitExe} -C "$PWD" rev-parse --git-common-dir 2>/dev/null || true)"
 92      git_common="$(realpath "$git_common" 2>/dev/null || true)"
 93
 94      # A genuine linked worktree has a `.git` file pointing strictly inside
 95      # the shared `.git`; a plain dir or crafted pointer binds nothing.
 96      if [ -f "$PWD/.git" ] && [ -n "$git_common" ]; then
 97        gitdir_ref="$(${sedExe} -n 's/^gitdir: //p' "$PWD/.git" 2>/dev/null | head -n1 || true)"
 98        gitdir="$(realpath "$gitdir_ref" 2>/dev/null || true)"
 99        case "$gitdir" in
100          "$git_common"/*) RUNTIME_ARGS+=(--bind "$git_common" "$git_common") ;;
101        esac
102      fi
103
104      # jj marks a linked workspace with a `.jj/repo` pointer file, relative to
105      # the `.jj` directory it sits in.
106      if [ -f "$PWD/.jj/repo" ]; then
107        jj_ref="$(cat "$PWD/.jj/repo" 2>/dev/null || true)"
108        case "$jj_ref" in
109          /*) : ;;
110          "") jj_ref="" ;;
111          *) jj_ref="$PWD/.jj/$jj_ref" ;;
112        esac
113        jj_store="$(realpath "$jj_ref" 2>/dev/null || true)"
114        if [ -d "$jj_store/store" ] && [ -d "$jj_store/op_store" ]; then
115          RUNTIME_ARGS+=(--bind "$jj_store" "$jj_store")
116
117          # A colocated repo keeps its git dir outside the store; jj reads it
118          # through the store's git_target pointer and needs it writable too.
119          jj_git=""
120          if [ -f "$jj_store/store/git_target" ]; then
121            jj_git="$(cat "$jj_store/store/git_target" 2>/dev/null || true)"
122          fi
123          if [ -n "$jj_git" ]; then
124            case "$jj_git" in
125              git:*) jj_git="$jj_store/store/git" ;;
126              /*) : ;;
127              *) jj_git="$jj_store/store/$jj_git" ;;
128            esac
129            jj_git="$(realpath "$jj_git" 2>/dev/null || true)"
130            if [ -d "$jj_git" ]; then
131              RUNTIME_ARGS+=(--bind "$jj_git" "$jj_git")
132            fi
133          fi
134        fi
135      fi
136    }
137    _ixBindSharedVcs
138  '';
139
140  # Prints NUL-separated bubblewrap args that load the project's dev shell,
141  # or nothing when direnv has not approved the project's `.envrc`.
142  devshellLoader = pkgs.writeShellApplication {
143    name = "ix-devshell-env";
144    runtimeInputs = [pkgs.coreutils];
145    text = ''
146      jail_path="''${IX_JAIL_PATH:-}"
147
148      # Only `.envrc` is trusted; loading a bare `.env` would leak its secrets.
149      status_json="$(${direnvExe} status --json 2>/dev/null || true)"
150      rc_path="$(printf '%s' "$status_json" | ${jqExe} -r '.state.foundRC.path // empty' 2>/dev/null || true)"
151      case "$rc_path" in
152        */.envrc) ;;
153        *) exit 0 ;;
154      esac
155      [ "$(printf '%s' "$status_json" | ${jqExe} -r '.state.foundRC.allowed // 9' 2>/dev/null || echo 9)" = 0 ] || exit 0
156
157      # `.envrc` can still pull in `.env` through `dotenv`; never forward its keys.
158      skip_keys='[]'
159      if [ -f "$PWD/.env" ]; then
160        skip_keys="$(${direnvExe} dotenv bash "$PWD/.env" 2>/dev/null \
161          | ${jqExe} -R -s 'gsub("export |;"; "\n") | split("\n") | map(split("=")[0]) | map(select(length > 0)) | unique' || true)"
162        [ -n "$skip_keys" ] || skip_keys='[]'
163      fi
164
165      # `env -i` yields a full diff; direnv reports only the delta otherwise.
166      pristine=(
167        "HOME=''${HOME:-}"
168        "USER=''${USER:-$(id -un)}"
169        "TERM=''${TERM:-dumb}"
170        "PATH=${devshellPath}"
171      )
172      for xdg in XDG_DATA_HOME XDG_CONFIG_HOME XDG_CACHE_HOME XDG_STATE_HOME; do
173        [ -n "''${!xdg:-}" ] && pristine+=("$xdg=''${!xdg}")
174      done
175
176      env_json="$(env -i "''${pristine[@]}" ${direnvExe} export json 2>/dev/null)" || exit 0
177      [ -n "$env_json" ] || exit 0
178
179      dev_path="$(printf '%s' "$env_json" | ${jqExe} -r '.PATH // empty' 2>/dev/null || true)"
180      if [ -n "$dev_path" ]; then
181        printf '%s\0%s\0%s\0' --setenv PATH "$dev_path''${jail_path:+:$jail_path}"
182      fi
183
184      # PATH is set above; the rest are paths the jail already controls.
185      printf '%s' "$env_json" | ${jqExe} -j --argjson skip "$skip_keys" '
186        to_entries[]
187        | select(.key | startswith("DIRENV_") | not)
188        | select(.value != null)
189        | select(.key as $k
190            | (["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "TEMPDIR", "NIX_BUILD_TOP"] + $skip)
191            | index($k) | not)
192        | "--setenv\u0000\(.key)\u0000\(.value)\u0000"
193      '
194    '';
195  };
196
197  devshellEnv = state: let
198    jailPath = state.env.PATH or "";
199  in
200    state
201    // {
202      runtime =
203        state.runtime
204        + ''
205          _ix_devshell_jail_path=${lib.escapeShellArg jailPath}
206          while IFS= read -r -d "" _ix_devshell_arg; do
207            RUNTIME_ARGS+=("$_ix_devshell_arg")
208          done < <(IX_JAIL_PATH="$_ix_devshell_jail_path" ${lib.getExe devshellLoader})
209        '';
210    };
211
212  mkJailedAgent = {
213    name,
214    pkg,
215    configPaths,
216    extraReadwriteDirs ? [],
217    extraReadonlyDirs ? [],
218    extraPkgs ? [],
219    env ? {},
220    fwdEnv ? [],
221    enableNix ? true,
222    enableDevshell ? true,
223    enableOpenUrls ? false,
224    enableWorkspaces ? true,
225    basePackages ? commonPkgs,
226    baseJailOptions ? commonJailOptions,
227  }: let
228    readonlyDirs =
229      [
230        "~/.config/agents"
231        "~/.config/git"
232        "~/.config/jj"
233      ]
234      ++ extraReadonlyDirs
235      ++ lib.optionals enableNix nixDaemonAccess.readonlyDirs;
236    readwriteDirs =
237      configPaths
238      ++ extraReadwriteDirs
239      ++ lib.optionals enableNix nixDaemonAccess.readwriteDirs;
240    extraPackages = extraPkgs ++ lib.optionals enableNix nixDaemonAccess.pkgs;
241  in {
242    jailed = jail name pkg (
243      with jail.combinators;
244        baseJailOptions
245        ++ lib.optionals enableOpenUrls [openUrls]
246        ++ lib.optionals enableWorkspaces [workspaceResolver]
247        ++ (map (p: readonly (noescape p)) readonlyDirs)
248        ++ [mount-cwd]
249        ++ (map (p: readwrite (noescape p)) readwriteDirs)
250        ++ [
251          (add-pkg-deps basePackages)
252          (add-pkg-deps extraPackages)
253        ]
254        ++ (map try-fwd-env fwdEnv)
255        ++ (lib.mapAttrsToList set-env env)
256        ++ lib.optionals (enableDevshell && enableNix) [devshellEnv]
257    );
258
259    unjailed = pkgs.writeShellScriptBin "${name}-unjailed" ''
260      exec ${lib.getExe pkg} "$@"
261    '';
262  };
263in {
264  inherit commonPkgs commonJailOptions mkJailedAgent;
265}