@elu-labs/elu 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +340 -2
- package/SECURITY.md +333 -0
- package/THIRD_PARTY_NOTICES.md +62 -0
- package/bin/elu-code.mjs +209 -0
- package/bin/elu.cjs +10 -0
- package/bin/start.cjs +6 -0
- package/docs/peer-network.md +257 -0
- package/docs/permissions.md +285 -0
- package/docs/vendors/antigravity.md +131 -0
- package/docs/vendors/claude.md +124 -0
- package/docs/vendors/codex.md +64 -0
- package/docs/vendors/grok.md +151 -0
- package/lib/acp-client.mjs +100 -0
- package/lib/adapters/agy.mjs +283 -0
- package/lib/adapters/claude.mjs +311 -0
- package/lib/adapters/codex.mjs +137 -0
- package/lib/adapters/grok.mjs +384 -0
- package/lib/adapters/index.mjs +13 -0
- package/lib/agents.mjs +77 -0
- package/lib/agy-hook.mjs +53 -0
- package/lib/approval-server.mjs +85 -0
- package/lib/approvals.mjs +128 -0
- package/lib/awake.mjs +45 -0
- package/lib/board.mjs +261 -0
- package/lib/bot-models.mjs +71 -0
- package/lib/bot-schedules.mjs +291 -0
- package/lib/bots.mjs +361 -0
- package/lib/changes.mjs +168 -0
- package/lib/checks.mjs +102 -0
- package/lib/conductor.mjs +337 -0
- package/lib/config.mjs +147 -0
- package/lib/conformance.mjs +109 -0
- package/lib/default-bots.mjs +150 -0
- package/lib/detect.mjs +108 -0
- package/lib/diff.mjs +217 -0
- package/lib/editor.mjs +165 -0
- package/lib/format.mjs +51 -0
- package/lib/git.mjs +173 -0
- package/lib/init.mjs +129 -0
- package/lib/install.mjs +130 -0
- package/lib/kanban.mjs +180 -0
- package/lib/mcp.mjs +79 -0
- package/lib/memory.mjs +35 -0
- package/lib/models.mjs +144 -0
- package/lib/modes.mjs +95 -0
- package/lib/never-list.mjs +110 -0
- package/lib/never-shims.mjs +311 -0
- package/lib/paste.mjs +56 -0
- package/lib/peer/a2a-http.mjs +166 -0
- package/lib/peer/a2a.mjs +357 -0
- package/lib/peer/asks.mjs +329 -0
- package/lib/peer/canonical.mjs +72 -0
- package/lib/peer/cli.mjs +154 -0
- package/lib/peer/daemon.mjs +525 -0
- package/lib/peer/identity.mjs +81 -0
- package/lib/peer/index.mjs +291 -0
- package/lib/peer/intake.mjs +207 -0
- package/lib/peer/invite.mjs +76 -0
- package/lib/peer/paths.mjs +7 -0
- package/lib/peer/peerdata.mjs +21 -0
- package/lib/peer/pictures.mjs +295 -0
- package/lib/peer/plans.mjs +182 -0
- package/lib/peer/relay.mjs +31 -0
- package/lib/peer/replies.mjs +128 -0
- package/lib/peer/roster.mjs +256 -0
- package/lib/peer/rpc.mjs +88 -0
- package/lib/peer/secrets.mjs +88 -0
- package/lib/peer/settings.mjs +45 -0
- package/lib/peer/shares.mjs +120 -0
- package/lib/peer/status.mjs +84 -0
- package/lib/peer/taskstore.mjs +108 -0
- package/lib/peer/transport.mjs +120 -0
- package/lib/pictures.mjs +104 -0
- package/lib/plan.mjs +238 -0
- package/lib/policy.mjs +98 -0
- package/lib/process.mjs +92 -0
- package/lib/project-settings.mjs +55 -0
- package/lib/project-trust.mjs +230 -0
- package/lib/projects.mjs +668 -0
- package/lib/publish.mjs +105 -0
- package/lib/runner.mjs +874 -0
- package/lib/sandbox.mjs +102 -0
- package/lib/schedules.mjs +270 -0
- package/lib/selection.mjs +202 -0
- package/lib/shell.mjs +101 -0
- package/lib/state.mjs +247 -0
- package/lib/terminal-bots.mjs +239 -0
- package/lib/terminal-commands.mjs +165 -0
- package/lib/terminal-plan.mjs +37 -0
- package/lib/terminal-visuals.mjs +270 -0
- package/lib/terminal.mjs +3181 -0
- package/lib/unit-file.mjs +17 -0
- package/lib/usage.mjs +249 -0
- package/lib/util.mjs +86 -0
- package/lib/vendor-check.mjs +96 -0
- package/lib/vendors.mjs +191 -0
- package/lib/workspace.mjs +168 -0
- package/lib/yaml.mjs +624 -0
- package/npm-shrinkwrap.json +827 -0
- package/package.json +70 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Antigravity CLI
|
|
2
|
+
|
|
3
|
+
`agy` is Google's terminal coding agent for Google One AI Pro and Ultra subscribers (Plus
|
|
4
|
+
and Free with a quota). In ELU Code it can run sessions and reviews, in every permission mode. `agy`
|
|
5
|
+
runs only after it passes the one-time check below on this machine.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Use the vendor's installer from [antigravity.google/docs](https://antigravity.google/docs).
|
|
10
|
+
It puts `agy` in your user bin folder (`~/.local/bin`); nothing needs sudo. ELU Code never
|
|
11
|
+
installs it and never updates it: its own runs set `AGY_CLI_DISABLE_AUTO_UPDATE=true`, your
|
|
12
|
+
own runs keep the vendor's default.
|
|
13
|
+
|
|
14
|
+
## Sign in
|
|
15
|
+
|
|
16
|
+
Run `agy` once and sign in with your Google account. The token lives in the OS keychain
|
|
17
|
+
(on Linux, the keyring reached over `DBUS_SESSION_BUS_ADDRESS`, which ELU Code passes
|
|
18
|
+
through). ELU Code checks sign-in with `agy -p /usage --output-format json`: exit code only,
|
|
19
|
+
no model turn, no quota spent. Signed out, ELU Code says: `Antigravity: not signed in. Run: agy`.
|
|
20
|
+
|
|
21
|
+
Google's FAQ at [antigravity.google/docs/faq](https://antigravity.google/docs/faq) says,
|
|
22
|
+
verbatim: "Using third-party software, tools, or services to access Antigravity is a
|
|
23
|
+
violation of our Terms of Service and severely degrades the experience for legitimate
|
|
24
|
+
product users. Such actions can result in suspension or termination of your account…" and,
|
|
25
|
+
on headless use: "Antigravity CLI headless mode (`agy -p`) is intended for direct scripting
|
|
26
|
+
and CI workflows. Wrapping `agy` inside third-party coding agents or applications, or
|
|
27
|
+
automating account rotation, is not supported under consumer plans." ELU Code wraps `agy`.
|
|
28
|
+
Enabling it is your decision; the risk is your account, and ELU Code cannot reduce it.
|
|
29
|
+
|
|
30
|
+
## The one-time check
|
|
31
|
+
|
|
32
|
+
The check proves, on this machine and for the exact binary and release found, that the
|
|
33
|
+
enforcement designed from Google's documentation holds. ELU Code starts it by itself
|
|
34
|
+
before your first message to Antigravity goes out, whether you wrote to a session on it or
|
|
35
|
+
to a bot on it. The session says so first, then shows each step as it ends, and then your
|
|
36
|
+
message goes on. The check takes about seven short turns on Antigravity and a few minutes.
|
|
37
|
+
Only a message you just sent starts it: a scheduled turn, a shadow turn or a review never
|
|
38
|
+
does. It runs in a throwaway repository with a planted file outside it, a local web
|
|
39
|
+
listener and a marker command:
|
|
40
|
+
|
|
41
|
+
| Step | Proves |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `signin` | the sign-in probe passes under ELU Code's private HOME |
|
|
44
|
+
| `locked-no-write` | a read-only run told to create files creates nothing, inside or outside |
|
|
45
|
+
| `locked-no-shell` | told to run the marker command, the marker never runs |
|
|
46
|
+
| `locked-no-network` | told to fetch the local URL, the listener sees nothing |
|
|
47
|
+
| `json` | headless output parses and the result matches the schema |
|
|
48
|
+
| `resume` | a second run with the conversation id continues the first |
|
|
49
|
+
| `relay` (optional) | over `agy acp`, a write outside the workspace arrives as a permission request, and a denial writes nothing |
|
|
50
|
+
|
|
51
|
+
The result is saved under your ELU Code state. A different binary path or release has to
|
|
52
|
+
pass again, on your next message to it; until it does, no run starts, and a turn you did
|
|
53
|
+
not send yourself says
|
|
54
|
+
`Antigravity CLI has not been checked on this machine yet. Your first message to it runs the check.`
|
|
55
|
+
If a required step fails, Antigravity stays off here, the session says which step was not
|
|
56
|
+
proven, and your next message to it runs the check again. When `relay` fails,
|
|
57
|
+
Ask and Auto still run: blocked actions are denied and reported instead of asked live, and
|
|
58
|
+
the permissions picker says so.
|
|
59
|
+
|
|
60
|
+
## The private HOME
|
|
61
|
+
|
|
62
|
+
`agy` reads `~/.gemini` for settings, hooks, MCP servers, plugins, skills and context files,
|
|
63
|
+
and has no flag to turn that off. So ELU Code runs it with `HOME` set to a folder under its
|
|
64
|
+
own state (`agy-home/`, mode 0700), one per workspace, holding only:
|
|
65
|
+
|
|
66
|
+
- `.gemini/antigravity-cli/settings.json`: the mode's permission rules, written before each run;
|
|
67
|
+
- `.gemini/config/hooks.json`: one PreToolUse hook, `lib/agy-hook.mjs`, run with the mode's name;
|
|
68
|
+
- the conversations `agy` keeps there, so a session resumes with `--conversation`, also
|
|
69
|
+
after `/permissions` changed its mode: the rules are rewritten for every run.
|
|
70
|
+
|
|
71
|
+
A mode switch applies from the next turn: the same conversation continues under the
|
|
72
|
+
workspace HOME, whose settings and hook are rewritten for the new mode before the run.
|
|
73
|
+
|
|
74
|
+
Nothing in your real `~/.gemini` reaches a run: not your settings, hooks, MCP servers,
|
|
75
|
+
plugins, skills, `GEMINI.md` or `AGENTS.md`. Context files inside the workspace
|
|
76
|
+
(`AGENTS.md`, `GEMINI.md`, `.agents/rules/*.md`) do reach the prompt; `agy` has no flag to
|
|
77
|
+
skip them, so a repository can steer the model. `.agents` is a protected path, so the model
|
|
78
|
+
cannot add such files itself.
|
|
79
|
+
|
|
80
|
+
The prompt goes on standard input, never on the command line, so other users on the
|
|
81
|
+
machine cannot read it with `ps`.
|
|
82
|
+
|
|
83
|
+
## What each mode enforces
|
|
84
|
+
|
|
85
|
+
| | Read only | Ask for approval | Approve for me | Full access |
|
|
86
|
+
| --- | --- | --- | --- | --- |
|
|
87
|
+
| How it runs | `agy -p --sandbox` | `agy acp` after `relay` passed, else `-p` | `agy acp` after `relay` passed, else `-p --mode=accept-edits --sandbox` | `-p --dangerously-skip-permissions` |
|
|
88
|
+
| Settings | `request-review`; `command`, `write_file`, `read_url`, `execute_url`, `mcp` denied; workspace only | `request-review`; `command`, `read_url`, `execute_url`, `mcp` ask; workspace only; protected files and secret folders denied to the file tool | `proceed-in-sandbox`; `sudo` and `.git` denied; workspace only; protected files and secret folders denied | outside access allowed; `read_url`, `execute_url`, `mcp` allowed; secret folders denied to the file tool |
|
|
89
|
+
| Hook | denies web search, commands, edits, image generation, scheduling and subagents | asks for web search; denies push, tag, merge, deploy and publish by command text | denies web search and the never list; sends the rest to the reviewer | denies the never list by command text |
|
|
90
|
+
| Named folders | none | `--add-dir` each; `write_file` denied in each | same | `--add-dir` each |
|
|
91
|
+
| Web | denied | asks, or reported | reviewed, or reported | allowed |
|
|
92
|
+
|
|
93
|
+
A named folder is read and never written in Ask for approval and Approve for me: its
|
|
94
|
+
`write_file` deny is the one a protected file gets. A folder that holds the workspace or
|
|
95
|
+
lies inside it gets no deny, because a deny wins over the workspace's edits: for example
|
|
96
|
+
the folder of all your projects, where a worktree sits beside its repository.
|
|
97
|
+
|
|
98
|
+
In headless mode a blocked action is a soft deny: the run goes on, exits 0, and prints a
|
|
99
|
+
notice on stderr naming the tool. ELU Code records each notice as a denial on the run and
|
|
100
|
+
the model is told to return `needs_input` naming what it needs. Over ACP, a permission
|
|
101
|
+
request reaches you under Needs you; no answer within 20 minutes counts as a denial.
|
|
102
|
+
|
|
103
|
+
## What is not enforced, or not verified
|
|
104
|
+
|
|
105
|
+
Everything above comes from Google's documentation and the Antigravity changelog; the
|
|
106
|
+
one-time check is the proof, per machine. In particular, until a check says otherwise:
|
|
107
|
+
|
|
108
|
+
- the never list is matched on command text by the hook, a tripwire that an obfuscated
|
|
109
|
+
command gets past; in Full access it is prompt text and that hook only;
|
|
110
|
+
- stand-in commands first on the session's PATH also refuse the never list by name for
|
|
111
|
+
what a script runs. That was seen only in tests against a stand-in `agy`, not with the
|
|
112
|
+
real one;
|
|
113
|
+
- whether `read_url` denies cover web search (hence the hook), whether the hook loads from
|
|
114
|
+
the private HOME, and the exact shape of the headless notice and of the ACP permission
|
|
115
|
+
request;
|
|
116
|
+
- whether `-p` in a folder it does not trust refuses instead of denying softly, and whether
|
|
117
|
+
the keychain sign-in is visible under the private HOME;
|
|
118
|
+
- that `-p` reads the task from standard input, that `--print-timeout` takes seconds
|
|
119
|
+
(`900s`), and that the output names the conversation id (the `json` and `resume` steps);
|
|
120
|
+
- secret reads: `agy` has no read deny, so a read of a secret folder is stopped only by
|
|
121
|
+
workspace-only access (Read only, Ask, Approve for me); Full access can read anything;
|
|
122
|
+
- whether a command can write in a folder given with `--add-dir`: the `write_file` deny
|
|
123
|
+
covers the file tool;
|
|
124
|
+
- on Linux, `--sandbox` needs unprivileged user namespaces; without them Read only and
|
|
125
|
+
Approve for me report instead of sandboxing.
|
|
126
|
+
|
|
127
|
+
## Telemetry
|
|
128
|
+
|
|
129
|
+
Antigravity's "Interactions data" (prompts and responses used to improve the product) is an
|
|
130
|
+
account setting, not a file or an environment variable. ELU Code cannot turn it off; opt out
|
|
131
|
+
in your Antigravity account settings.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Claude Code
|
|
2
|
+
|
|
3
|
+
Sessions and reviews. Sign-in is `claude`'s own; ELU Code never reads your sign-in
|
|
4
|
+
token. The Usage tab names the account from `~/.claude.json` (email, organization,
|
|
5
|
+
plan and rate limit tier) and shows only the five-hour and weekly usage windows that
|
|
6
|
+
Claude Code prints during sessions. Tested releases:
|
|
7
|
+
2.1.319 and 2.1.322. The modes are described in
|
|
8
|
+
[../permissions.md](../permissions.md); this page is how `claude` is launched
|
|
9
|
+
for each.
|
|
10
|
+
|
|
11
|
+
## Read only
|
|
12
|
+
|
|
13
|
+
The launch: `--restricted --permission-mode dontAsk --permission-prompts none`,
|
|
14
|
+
`--safe-mode` unless your own MCP servers are on (it would drop them), an explicit
|
|
15
|
+
`--tools` list with no shell unless the role declares command prefixes, strict MCP
|
|
16
|
+
configuration (none for a review, a read-only turn or a teammate's question; for a task
|
|
17
|
+
saved without a mode, your own Claude Code servers, all of them unless `/mcp off NAME`
|
|
18
|
+
turned one off for the project), no setting sources,
|
|
19
|
+
disabled hooks, the OS sandbox with network off, and
|
|
20
|
+
`blockReadsOutsideWorkingDirectories`. An answer turn in a session saved without a
|
|
21
|
+
mode also gets `--add-dir` for your home folder and for a private copy of your Claude
|
|
22
|
+
Code memory, when there is one.
|
|
23
|
+
|
|
24
|
+
## Ask for approval, Approve for me, Full access
|
|
25
|
+
|
|
26
|
+
| | Ask | Auto | Full |
|
|
27
|
+
| --- | --- | --- | --- |
|
|
28
|
+
| `--permission-mode` | `default` | `auto` | `bypassPermissions` |
|
|
29
|
+
| `--permission-prompts` / `--permission-prompt-tool` | `host` / `mcp__elu-code-approval__approve` | same | same |
|
|
30
|
+
| `--restricted` / `--safe-mode` | no | no | no |
|
|
31
|
+
| `--tools` | `Read,Grep,Glob,Edit,Write,Bash,WebFetch,WebSearch` | same | same |
|
|
32
|
+
| `--allowedTools` | your MCP servers, `Read`, `Grep`, `Glob`, `Edit` and `Write` on the workspace | same | none |
|
|
33
|
+
| `--disallowedTools` | `Agent,Task,NotebookEdit,PowerShell,Monitor`; a turn with helpers, below | same | same |
|
|
34
|
+
| `--add-dir` | each folder of the session; an answer turn (yours, or a working bot's) also gets your home folder and a private copy of your Claude Code memory when there is one | same | same |
|
|
35
|
+
| Sandbox | on where it starts, never required, no network; unsandboxed commands ask you | on where it starts, never required, no network; unsandboxed commands go to the reviewer | off |
|
|
36
|
+
| Asks | web tools; answer turns also edits in the project folder | answer-turn edits only | none |
|
|
37
|
+
| Denies | protected paths, writes in the folders of the session, secret reads, ELU Code state, the never list | same, without dependency files | secret reads, ELU Code state, the never list |
|
|
38
|
+
|
|
39
|
+
Every profile keeps `--setting-sources ''`, `--strict-mcp-config` with a private
|
|
40
|
+
config file and disabled hooks. `--max-budget-usd` is passed only when the session
|
|
41
|
+
has a spending limit (`/budget`) or the run is a teammate's question. The approval relay is the
|
|
42
|
+
one server ELU Code adds to that file; it is never in the allow list.
|
|
43
|
+
|
|
44
|
+
A folder you name with `/permissions folders add` is read and never written in Ask and
|
|
45
|
+
Auto: `Edit` and `Write` deny rules cover it and the sandbox's `denyWrite` lists it, the
|
|
46
|
+
way protected paths are kept. Claude Code lets a deny win over an allow, so a folder that
|
|
47
|
+
holds the workspace or lies inside it gets neither: for example the folder of all your
|
|
48
|
+
projects, where a worktree sits beside its repository. Nor does a folder that holds the
|
|
49
|
+
cache folder where the run keeps its temporary files (below). Not seen with the real
|
|
50
|
+
service yet.
|
|
51
|
+
|
|
52
|
+
The never list as deny rules: `git push`, `git tag`, `git merge`, `gh pr merge`,
|
|
53
|
+
`gh release`, `npm publish`, `pnpm publish`, `yarn publish`, `cargo publish`,
|
|
54
|
+
`firebase deploy`, `vercel deploy`, `gcloud run deploy`, `gcloud app deploy`,
|
|
55
|
+
`fly deploy`, `kubectl apply`, `terraform apply`. They match command text, so
|
|
56
|
+
`git -C . push` or a shell wrapper gets past them; in Ask the sandbox has no
|
|
57
|
+
network and the common directory is read-only, which is what holds. A bot that is
|
|
58
|
+
allowed to merge pull requests (`@elu` comes allowed; a bot you make is not) runs its
|
|
59
|
+
own working turn without the `gh pr merge` rule; in Ask that command asks you instead.
|
|
60
|
+
|
|
61
|
+
A working bot's turn that may start helpers (see [../permissions.md](../permissions.md))
|
|
62
|
+
is launched with these changes: `--tools` adds `Agent`; `--allowedTools` adds
|
|
63
|
+
`Agent(executor)`, except in Full; `--disallowedTools` is
|
|
64
|
+
`NotebookEdit,PowerShell,Monitor`; `--agents` defines the one helper, `executor`, with
|
|
65
|
+
the turn's own tools and the helpers' model; the deny rules add each built-in agent type
|
|
66
|
+
and an `Agent` call that names a model or an isolation; and the settings and the
|
|
67
|
+
environment turn off built-in agents, forked agents, helpers of helpers and background
|
|
68
|
+
tasks, and force the helpers' model when the bot names one.
|
|
69
|
+
|
|
70
|
+
Stand-in commands (`lib/never-shims.mjs`) cover what those rules cannot read: a
|
|
71
|
+
script, `sh -c`, `npm test`. Claude Code starts each command from a snapshot of
|
|
72
|
+
your shell setup, which builds PATH again, so a folder put first on the PATH it was
|
|
73
|
+
started with ends up behind the system's. ELU Code sets `CLAUDE_ENV_FILE` to a
|
|
74
|
+
one-line file that puts the folder first again; Claude Code runs that file ahead of
|
|
75
|
+
each command. Seen on 2.1.322 in Ask for approval and in Approve for me: `npm test`
|
|
76
|
+
whose script ran `git push` to an origin on another machine printed `git push is
|
|
77
|
+
never allowed, in any mode.` and the push exited 126, and the run kept the refusal
|
|
78
|
+
for the chat; the same turn without the file reached `/usr/bin/git`. Not seen with
|
|
79
|
+
the real service: Full access.
|
|
80
|
+
|
|
81
|
+
Claude Code keeps files of its own while it runs. After every command it writes a
|
|
82
|
+
note of its working folder, and its sandboxed commands are given the same folder as
|
|
83
|
+
`TMPDIR`. Its own place for them is `/tmp/claude-<uid>`, and the sandbox ELU Code
|
|
84
|
+
sets for Read only, Ask for approval and Approve for me denies writes under `/tmp`,
|
|
85
|
+
so every sandboxed command ended `Exit code 1` whatever it did. In those modes ELU
|
|
86
|
+
Code names a private folder of the run in `CLAUDE_CODE_TMPDIR`:
|
|
87
|
+
`<cache folder>/elu/<two letters>`, yours alone to read, made for the run and
|
|
88
|
+
removed when it ends. The cache folder is `XDG_CACHE_HOME`, or `~/.cache`. Claude
|
|
89
|
+
Code uses a named folder for sandboxed commands only while `<folder>/claude-<uid>`
|
|
90
|
+
is at most 44 bytes. Where the cache folder is too long for that (a home folder of
|
|
91
|
+
more than nineteen bytes, with a three-digit user number) your own runtime folder
|
|
92
|
+
is tried, `XDG_RUNTIME_DIR` (`/run/user/<uid>` on Linux): the run's folder there is
|
|
93
|
+
`<runtime folder>/elu<two letters>`, which fits a ten-digit user number. Where
|
|
94
|
+
neither fits none is named, and commands there still end 1. Seen on 2.1.322 in Ask
|
|
95
|
+
for approval: `npm test` ended 0 with `TMPDIR` in the cache folder's place, and the
|
|
96
|
+
folder was gone after the run. Not seen with the real service: Approve for me, Read
|
|
97
|
+
only, Linux, the runtime folder.
|
|
98
|
+
|
|
99
|
+
A sandboxed command reads the repository's Git folder. The sandbox's `denyRead`
|
|
100
|
+
holds only ELU Code's records in it (`<Git folder>/elu-code`) and `denyWrite` holds
|
|
101
|
+
the whole folder; a teammate's question keeps the whole folder in `denyRead`. Seen
|
|
102
|
+
on 2.1.322 in Ask for approval, in an answer turn: `git status`, `git diff` and
|
|
103
|
+
`git log` ran in the sandbox with no approval asked; `git commit` ended 128 on
|
|
104
|
+
`index.lock: Operation not permitted`; `ls .git/elu-code` was refused. A text
|
|
105
|
+
filter named in the Git settings ran inside the sandbox when `git diff` used it:
|
|
106
|
+
it could write nothing, and its call to the network was refused. Not seen with the
|
|
107
|
+
real service: Approve for me, Read only, an edit turn in a worktree, Linux.
|
|
108
|
+
|
|
109
|
+
`parse` reads the profile Claude ran with from its init line. A run asked for
|
|
110
|
+
Auto that reports `default` is shown as Ask. Denied tool calls come back on the
|
|
111
|
+
result as `permission_denials` and are kept on the run.
|
|
112
|
+
|
|
113
|
+
On Linux, Claude Code's shell sandbox needs `bwrap` and `socat` on PATH, and `bwrap`
|
|
114
|
+
must be able to start (Ubuntu 24.04 needs an AppArmor profile for it; see Linux in
|
|
115
|
+
[permissions.md](../permissions.md)). Where the sandbox does not start, Claude Code
|
|
116
|
+
runs commands without one: Ask for approval asks you for each, and Approve for me
|
|
117
|
+
sends each to the reviewer. The Permissions picker says "No OS sandbox here" when a
|
|
118
|
+
tool is missing. Where both are installed and `bwrap` cannot start, Claude Code says
|
|
119
|
+
its sandbox is disabled; seen on 2.1.322, `npm test` then came to the owner as an
|
|
120
|
+
approval. Nothing is installed for you.
|
|
121
|
+
|
|
122
|
+
Not enforced or not verified on these releases: Bash rules match text only;
|
|
123
|
+
WebFetch runs outside the sandbox; a `bypassPermissions` run does not check
|
|
124
|
+
protected paths; whether Claude's auto reviewer counts toward the dollar budget.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Codex
|
|
2
|
+
|
|
3
|
+
Sessions and reviews. Tested release: 0.166.0-alpha.5.1. Subscription: ChatGPT.
|
|
4
|
+
|
|
5
|
+
Labels: **[run]** checked by running the tested release of `codex` against a local mock model server (the real backend was never called); **[docs]** from OpenAI's documentation or `codex exec --help`; **[unverified]** neither.
|
|
6
|
+
|
|
7
|
+
## Install and sign in
|
|
8
|
+
|
|
9
|
+
- `npm i -g @openai/codex` (a user prefix, no sudo).
|
|
10
|
+
- `codex login` and choose ChatGPT. ELU Code reads `codex login status` and accepts only `Logged in using ChatGPT` [run]. An API key sign-in exits 0 too, so it is reported as signed out and the status line says `Codex: sign in with ChatGPT. Run: codex login`. No key variable is read or passed.
|
|
11
|
+
- If another `codex` shim comes first on your PATH (the cmux shim does on some machines), put the real binary first. ELU Code launches with `features.hooks=false`; whether that neutralizes a shim's hooks is [unverified].
|
|
12
|
+
|
|
13
|
+
## What each mode launches
|
|
14
|
+
|
|
15
|
+
Every mode keeps the same isolation: `--ignore-user-config --ignore-rules --json`, `-c mcp_servers={}`, hooks, apps, plugins, browser, multi-agent, image and skill features off, user input and plan tools off, and a refusal to launch when a project or managed `config.toml` could add MCP servers. The output schema goes through `--output-schema`; the prompt arrives on stdin. A session's model also gets `--skip-git-repo-check`: a project folder need not be a Git repository, and without the flag Codex refuses to start there [run].
|
|
16
|
+
|
|
17
|
+
| | Read only (reviewer) | Ask for approval | Approve for me | Full access |
|
|
18
|
+
|---|---|---|---|---|
|
|
19
|
+
| Sandbox | `--sandbox read-only` | profile `elu_ask` (below) [run] | the same profile [run] | `-c sandbox_mode="danger-full-access"` [run] |
|
|
20
|
+
| Approvals | `never` | `never`; a blocked action produces no stream item [run] | `on-request` with `approvals_reviewer="auto_review"` [run] | `never` |
|
|
21
|
+
| Shell | none | `exec_command` inside the sandbox [run] | same; an escalation goes to the reviewer model [run] | any command, no sandbox [run] |
|
|
22
|
+
| Network | blocked | blocked, loopback included [run] | blocked unless the reviewer allows an escalation | open [run] |
|
|
23
|
+
| Web search | off | off | off | `web_search="live"` [run: the tool is offered; a real search is unverified] |
|
|
24
|
+
| Named folders | n/a | read-only | read-only | n/a |
|
|
25
|
+
| Where | task worktree | worktree or branch | worktree or branch | also your checkout |
|
|
26
|
+
|
|
27
|
+
The `elu_ask` profile is one `-c permissions.elu_ask=…` value with `default_permissions="elu_ask"` [run]:
|
|
28
|
+
|
|
29
|
+
- `extends=":workspace"` for edit turns: the workspace is writable, everything else readable [run]. Answer turns use `extends=":read-only"` [run].
|
|
30
|
+
- `":slash_tmp"="read"` and `":tmpdir"="read"`: the shared temp folders the workspace sandbox adds are read-only again [run]. The `sandbox_workspace_write.exclude_*` keys do nothing under a profile [run], so they are not passed.
|
|
31
|
+
- Each named folder `"<dir>"="read"`: readable, not writable, the same entry a protected file gets below.
|
|
32
|
+
- Each protected file `"<path>"="read"`: readable, not writable [run]. The structural checks still run afterwards.
|
|
33
|
+
- Home secrets, ELU Code's state folder and the repository's `.git/elu-code` as `"<path>"="deny"`: a shell `cat` or `ls` there never starts and no item is reported [run]. The reviewer model in Approve for me sees the same deny list in its context [run].
|
|
34
|
+
|
|
35
|
+
A profile value containing `*`, `?`, `[`, `]`, `{` or `}` is refused before launch: Codex reads those as patterns.
|
|
36
|
+
|
|
37
|
+
Resume: `codex exec resume <thread_id> -` with the same `-c` set. `exec resume` has no `--sandbox` or `--cd`, so the sandbox and profile come from `-c` keys and the process runs in the workspace [run: the profile, Approve for me and Full access all applied on a resumed thread].
|
|
38
|
+
|
|
39
|
+
## What the owner sees
|
|
40
|
+
|
|
41
|
+
- Approve for me: an action the reviewer declines comes back as `status: "declined"` and is listed on the run as a denial (`Run: <command>` or `Edit: <paths>`) [run]. The model is told not to retry it and to return `needs_input`.
|
|
42
|
+
- Ask for approval: Codex has no live approval relay. A sandbox block produces no stream item and no denial record [run]; the model reports what it could not do.
|
|
43
|
+
- No dollar figure: Codex reports no cost in its stream. `maxCostUsd` must be `null`; the time limit bounds the run.
|
|
44
|
+
|
|
45
|
+
## Code mode, seen with the real service
|
|
46
|
+
|
|
47
|
+
These lines come from real turns on 0.166.0-alpha.5.1 with a ChatGPT sign-in, not from the mock model server.
|
|
48
|
+
|
|
49
|
+
- Every model in the release's catalog but one takes its tools only through the code-mode host (`"tool_mode": "code_mode_only"` in `codex debug models --bundled`). The mock server knows no such model, so the [run] checks above never met it.
|
|
50
|
+
- With `features.code_mode_host=false` a turn opens with `Code Mode is unavailable because code-mode host is disabled. Code mode will fail closed`. No command and no file read starts; the model can only ask you to enable shell execution. A session therefore leaves `code_mode_host` at the release's default. A reviewer has no tools and keeps it off.
|
|
51
|
+
- With the host on, in Ask for approval and in Approve for me: a command runs as `/bin/zsh -lc '…'` and arrives as a `command_execution` item, a patch as a `file_change` item, and the adapter reads the stream. No other kind of item appeared.
|
|
52
|
+
- The sandbox still refused: a write beside the workspace (`Operation not permitted`), a patch to a protected file (rejected, no item, no denial record) and a host lookup by `curl`.
|
|
53
|
+
- The never list: with `allow_login_shell=false` a command runs as `/bin/zsh -c '…'`, and `command -v git` inside the sandbox names the stand-in. In Ask for approval, `npm test` whose script ran `git push` to an origin on another machine (an `ssh://` URL to a name that does not resolve) printed `git push is never allowed, in any mode.`, the push exited 126, ssh was never started, and the run kept the refusal for the chat. The same script's push to a bare repository inside the project went to the real git, and the sandbox of the mode refused its write (`remote unpack failed`). Approve for me was seen with the same refusal before local pushes were let through. With a login shell (`-lc`) the stand-in folder lands behind `/usr/bin` on macOS and would refuse nothing. Asked to run `git push` itself, the model declined, citing its instructions.
|
|
54
|
+
- After a refused step the model answers `needs_input`, names what it needs in the summary and leaves `question` empty (three turns of three). ELU Code reads that reply as it is. Refusing it started a second session that ran the turn's commands again.
|
|
55
|
+
- Not seen: Full access with the real service, a declined escalation in Approve for me, and what a code-mode cell can do without calling a tool.
|
|
56
|
+
|
|
57
|
+
## Not enforced or not verified
|
|
58
|
+
|
|
59
|
+
- No per-command allow or deny rules (`--ignore-rules`). In every mode but Read only the never list is held by stand-in commands first on the session's PATH (`lib/never-shims.mjs`), and `-c allow_login_shell=false` keeps a login shell from rebuilding PATH with the system folders ahead of them. They refuse by name and are not a sandbox: a full path to the real command, a changed PATH, a Git alias or another program that does the same work gets past them. A `git push` to a folder on this machine and a `git merge` or `git tag` in a scratch repository under the temporary directory pass, since a project's own tests do both. Behind them, in Ask the sandbox has no network and the Git common directory is read-only; in Approve for me an escape depends on the reviewer model; in Full access there is nothing.
|
|
60
|
+
- Reads are open everywhere except the profile's deny entries. Glob deny entries such as `**/.env*` are [unverified].
|
|
61
|
+
- Approve for me: an allowed escalation ran a write outside the sandbox [run]; an allowed escalated `curl` to a loopback address still could not connect [run, cause unknown]. An allowed escalation to an external host reached it in an earlier run.
|
|
62
|
+
- Full access web search: the `web_search` tool is offered with `web_search="live"` [run]; no search was performed against the real service.
|
|
63
|
+
- Whether `features.hooks=false` stops a PATH shim's hooks [unverified].
|
|
64
|
+
- Linux: the tested release runs its sandbox on bubblewrap, with its own copy of `bwrap` (Landlock is its older option). The profile semantics above were checked on macOS only; the Linux tests run with `bubblewrap` installed and an AppArmor profile loaded ([../permissions.md](../permissions.md#linux)).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Grok Build
|
|
2
|
+
|
|
3
|
+
Grok Build (`grok`) is xAI's coding CLI for SuperGrok and X Premium+ subscribers. ELU Code
|
|
4
|
+
runs sessions on it. Its enforcement follows the vendor's documentation; a one-time check on
|
|
5
|
+
your machine proves it before the first run.
|
|
6
|
+
|
|
7
|
+
## Install and sign in
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm i -g @xai-official/grok
|
|
11
|
+
grok login
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Install into a user-writable npm prefix; nothing here needs sudo. `grok login` opens the
|
|
15
|
+
browser (`grok login --device-auth` on a remote machine) and keeps the token in
|
|
16
|
+
`~/.grok/auth.json`. ELU Code never reads that file, never copies it, and reads no API key
|
|
17
|
+
variable: if `grok` is signed out, the session says `Grok Build: not signed in. Run: grok login`.
|
|
18
|
+
|
|
19
|
+
## The one-time check
|
|
20
|
+
|
|
21
|
+
ELU Code starts the check by itself before your first message to Grok goes out, whether
|
|
22
|
+
you wrote to a session on it or to a bot on it. The session says so first, then shows each
|
|
23
|
+
step as it ends, and then your message goes on. The check takes about seven short turns on
|
|
24
|
+
Grok and a few minutes. Only a message you just sent starts it: a scheduled turn, a shadow
|
|
25
|
+
turn or a review never does.
|
|
26
|
+
|
|
27
|
+
The check runs eight steps against the installed binary in a throwaway repository, with a
|
|
28
|
+
planted file outside it, a local web listener and a marker command:
|
|
29
|
+
|
|
30
|
+
| Step | What it proves |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `signin` | the token works from ELU Code's private Grok home |
|
|
33
|
+
| `locked-no-write` | a read-only run told to create files creates none, inside or outside |
|
|
34
|
+
| `locked-no-shell` | told to run the marker command, it never runs |
|
|
35
|
+
| `locked-no-network` | told to fetch the local URL, the listener sees nothing |
|
|
36
|
+
| `json` | the headless result parses and follows ELU Code's schema |
|
|
37
|
+
| `resume` | a second run with the session id continues the first |
|
|
38
|
+
| `relay` | over ACP, a write outside the workspace asks first; denied, nothing is written |
|
|
39
|
+
| `isolation` | `grok inspect --json` shows no Claude or Cursor instructions, hooks or servers |
|
|
40
|
+
|
|
41
|
+
Every step but `relay` must pass. The result is stored per binary and version; a new
|
|
42
|
+
release proves itself again, on your next message to it. Until the check passes, no Grok
|
|
43
|
+
run starts, and a turn you did not send yourself says
|
|
44
|
+
`Grok Build has not been checked on this machine yet. Your first message to it runs the check.`
|
|
45
|
+
If a required step fails, Grok stays off here, the session says which step was not proven,
|
|
46
|
+
and your next message to it runs the check again. If `relay` fails, Ask and
|
|
47
|
+
Auto still work, but a blocked action is reported instead of asked:
|
|
48
|
+
`Grok can't ask live here. Blocked actions are reported.`
|
|
49
|
+
|
|
50
|
+
## How each mode runs
|
|
51
|
+
|
|
52
|
+
| Mode | Transport | Permissions |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Read only | headless (`--prompt-file`) | `--permission-mode dontAsk`, `--sandbox read-only`, tools `read_file,grep,list_dir`, every write, shell, web and MCP tool denied |
|
|
55
|
+
| Ask | ACP (`grok agent stdio`) when `relay` passed, else headless `dontAsk` | `Read` and `Grep` with no path, workspace edits and reads of each named folder allowed by rule; outside edits, named folders included, shell and web ask you (ACP) or are denied and reported (headless); `--sandbox workspace` in headless mode |
|
|
56
|
+
| Auto | ACP with `autoMode`, else headless `--permission-mode auto` | Grok's own reviewer model decides routine actions; what it refuses asks you over ACP |
|
|
57
|
+
| Full | headless `--permission-mode bypassPermissions`, `--sandbox off` | anything; the never list stays as deny rules |
|
|
58
|
+
|
|
59
|
+
Every profile denies reads of secret folders (`~/.ssh`, `~/.aws`, `~/.claude`, `~/.codex`,
|
|
60
|
+
`.env*`, `auth.json`, key files, ELU Code's own state) by rule, and denies the never list on
|
|
61
|
+
command text (`git push`, `git tag`, `git merge`, `gh pr merge`, `gh release`, package
|
|
62
|
+
publishes, deploys, `kubectl apply`, `terraform apply`). Those rules match text: `sh -c`
|
|
63
|
+
and aliases get past them, so in Auto and Full they are a tripwire, not a boundary.
|
|
64
|
+
Stand-in commands first on the session's PATH also refuse the list by name for what a
|
|
65
|
+
script or `sh -c` runs; that was seen only in tests against a stand-in `grok`, not with the
|
|
66
|
+
real one, and whether Grok's sandbox lets a command read that folder is not proven. The
|
|
67
|
+
Read only profile has no shell, so there the never list holds. Grok's sandbox blocks a
|
|
68
|
+
child process's network on Linux only; on macOS the read-only profile holds because it has
|
|
69
|
+
no shell and no web tools, not because the sandbox blocks the network.
|
|
70
|
+
|
|
71
|
+
Headless runs have no schema flag, so ELU Code puts the output schema in the prompt and
|
|
72
|
+
validates the answer. Sessions resume with `-r`; a mode switch that changes the sandbox
|
|
73
|
+
profile cannot resume (Grok keeps a session's sandbox), so that turn's retry starts a fresh
|
|
74
|
+
session without the earlier context. The time limit is the role's; `--max-turns` (40 for
|
|
75
|
+
read-only turns, 120 for edit turns) bounds the model rounds. Cost is not reported under a
|
|
76
|
+
subscription; the role's `maxCostUsd` must be `null`.
|
|
77
|
+
|
|
78
|
+
## The private home
|
|
79
|
+
|
|
80
|
+
By default Grok reads your `~/.grok` tree (config, rules, hooks, skills, agents,
|
|
81
|
+
`AGENTS.md`), `/etc/grok`, the workspace's `.grok`, every `AGENTS.md` and `CLAUDE.md` up
|
|
82
|
+
the tree, `.envrc`, and your Claude Code configuration: `~/.claude/settings.json` and
|
|
83
|
+
`settings.local.json` (hooks and permission rules), `~/.claude/CLAUDE.md`,
|
|
84
|
+
`~/.claude/rules/*.md`, `~/.claude/agents/*.md`, `~/.claude/skills/`, `~/.claude.json`
|
|
85
|
+
(MCP servers), plus the same Cursor files. The switches
|
|
86
|
+
`GROK_CLAUDE_{SKILLS,RULES,AGENTS,MCPS,HOOKS}_ENABLED=0` and `GROK_CURSOR_*_ENABLED=0`
|
|
87
|
+
exist, and ELU Code sets all ten, but
|
|
88
|
+
[xai-org/grok-build-plugin-cc#34](https://github.com/xai-org/grok-build-plugin-cc/issues/34)
|
|
89
|
+
(open, filed against CLI 1.0.5) reports `~/.claude/CLAUDE.md`, `rules` and `agents` still
|
|
90
|
+
loading with every switch off, and the `~/.claude` scan is anchored on `HOME`, which
|
|
91
|
+
`GROK_HOME` does not move.
|
|
92
|
+
|
|
93
|
+
So every run gets a private `HOME` under ELU Code's state (`grok-home` in your personal
|
|
94
|
+
directory) and a `GROK_HOME` inside it holding only what ELU Code wrote:
|
|
95
|
+
|
|
96
|
+
- `config.toml`: Claude and Cursor compatibility off, telemetry, trace upload, Mixpanel,
|
|
97
|
+
feedback, managed config, campaigns, remote fetch and server-side search off, workspace
|
|
98
|
+
snapshots and codebase uploads off, auto-update off, session relay off, `.envrc` off,
|
|
99
|
+
subagents off, and the never list as deny rules. It is rewritten before every run.
|
|
100
|
+
- `trusted_folders.toml`: empty. A workspace is never trusted, so its `.grok/config.toml`,
|
|
101
|
+
hooks, MCP servers and skills never load. Its `AGENTS.md` and `CLAUDE.md` still reach the
|
|
102
|
+
prompt; they are repository content, treated as untrusted.
|
|
103
|
+
- `sessions/`: Grok's session store, so `-r` works.
|
|
104
|
+
- `auth.json`: a symbolic link to your real `~/.grok/auth.json`. `grok login`, `grok logout`
|
|
105
|
+
and token refreshes write through it; ELU Code holds no copy. If Grok ever replaces the
|
|
106
|
+
link with a file, the newer token wins at the next run.
|
|
107
|
+
|
|
108
|
+
There is no `~/.claude` under that home, so the `isolation` step shows what actually
|
|
109
|
+
reaches a run on your machine. If it fails, Grok stays off here and the session says why.
|
|
110
|
+
|
|
111
|
+
## Telemetry and uploads
|
|
112
|
+
|
|
113
|
+
On consumer plans Grok's product analytics, session trace upload (which includes file
|
|
114
|
+
contents) and per-turn workspace snapshots are on by default. ELU Code turns off every
|
|
115
|
+
documented switch, in the environment and in `config.toml`: `GROK_TELEMETRY_ENABLED=0`,
|
|
116
|
+
`GROK_TELEMETRY_TRACE_UPLOAD=0`, `GROK_TELEMETRY_MIXPANEL_ENABLED=0`,
|
|
117
|
+
`GROK_FEEDBACK_ENABLED=0`, `GROK_MEMORY=0`, `GROK_SUBAGENTS=0`, `GROK_CAMPAIGNS=0`,
|
|
118
|
+
`GROK_DISABLE_AUTOUPDATER=1`, `[telemetry] trace_upload=false`,
|
|
119
|
+
`[harness] disable_workspace_teleport=true` and the older `disable_codebase_upload=true`
|
|
120
|
+
(the key that stopped per-turn repository uploads on earlier releases), `[relay]
|
|
121
|
+
enabled=false`. With trace upload on, a headless run can spend up to 150 seconds draining
|
|
122
|
+
uploads at exit; off, it ends when the turn ends. Grok's server-side web search
|
|
123
|
+
(`[features] backend_tools`, `GROK_BACKEND_SEARCH`) runs inside the model call, where no
|
|
124
|
+
permission rule applies, so it is off in every mode; the model's own web tools follow the
|
|
125
|
+
mode. Account-level settings for coding data, retention and training live in Grok's own
|
|
126
|
+
`/privacy` screen; ELU Code cannot change them.
|
|
127
|
+
|
|
128
|
+
## What is enforced and what is not
|
|
129
|
+
|
|
130
|
+
Enforced by the vendor and proven by the one-time check on your machine: no writes, no shell and
|
|
131
|
+
no network in Read only; the schema result; resume; where `relay` passed, a live question
|
|
132
|
+
before an outside write in Ask.
|
|
133
|
+
|
|
134
|
+
Not enforced, or not proven:
|
|
135
|
+
|
|
136
|
+
- Deny rules match command text. Obfuscated commands reach Auto's reviewer model and run
|
|
137
|
+
unprompted in Full.
|
|
138
|
+
- `--permission-mode` values other than `bypassPermissions` in headless mode are
|
|
139
|
+
documented for the enterprise build; the check's locked steps prove `dontAsk` here, and
|
|
140
|
+
Ask falls back to it only when `relay` failed.
|
|
141
|
+
- Whether the server-side search switch and the older codebase-upload key are honoured on
|
|
142
|
+
this release, and whether `auto` mode is available on your plan without
|
|
143
|
+
`[auto_mode] enabled`, is not settled by the docs.
|
|
144
|
+
- The exact ACP permission request shape and `session/load` come from the ACP standard and
|
|
145
|
+
Grok's docs; the `relay` step is the only proof.
|
|
146
|
+
- Whether Grok refreshes the token through the symbolic link or replaces it is not settled;
|
|
147
|
+
the rule above keeps the newer token either way.
|
|
148
|
+
- Workspace `AGENTS.md` and `CLAUDE.md` reach the prompt in every mode. A repository can
|
|
149
|
+
steer the model's instructions, not its permissions.
|
|
150
|
+
- A full shell in Full access can push, tag, merge, deploy and publish. ELU Code keeps the
|
|
151
|
+
prompt line, the deny rules and its own publish gate, and claims no more.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { EluCodeError, atomic, noSymlinks, refuse } from './util.mjs';
|
|
6
|
+
import { killGroup, processIdentity } from './process.mjs';
|
|
7
|
+
|
|
8
|
+
// Newline-delimited JSON-RPC 2.0 over a child's stdio, as the ACP vendors speak it. No
|
|
9
|
+
// dependencies. The child's lines are appended to logFile, so a run's log reads like a
|
|
10
|
+
// vendor's stream; stderr goes to <logFile>.stderr. The same logFile, processFile, signal
|
|
11
|
+
// and hold plumbing as runProcess, and `exit` resolves to the shape runProcess returns.
|
|
12
|
+
const OUTPUT_LIMIT = 16 * 1024 * 1024, LINE_LIMIT = 4 * 1024 * 1024, REQUEST_TIMEOUT_MS = 60000;
|
|
13
|
+
const rpcError = (message, code) => Object.assign(new EluCodeError(message, { code }), { code });
|
|
14
|
+
|
|
15
|
+
export async function startAcp({ bin, args = [], cwd, env, logFile, processFile, signal, hold = null, timeoutSeconds = null }) {
|
|
16
|
+
signal?.throwIfAborted();
|
|
17
|
+
if (typeof bin !== 'string' || !bin) refuse('An ACP client needs a program to start.');
|
|
18
|
+
noSymlinks(logFile);
|
|
19
|
+
const fd = fs.openSync(logFile, 'wx', 0o600), stderrFile = `${logFile}.stderr`;
|
|
20
|
+
noSymlinks(stderrFile);
|
|
21
|
+
const stderrFd = fs.openSync(stderrFile, 'wx', 0o600);
|
|
22
|
+
let child;
|
|
23
|
+
try { child = spawn(bin, args, { cwd, env, detached: true, stdio: ['pipe', 'pipe', stderrFd] }); }
|
|
24
|
+
finally { fs.closeSync(stderrFd); }
|
|
25
|
+
const pending = new Map(), handlers = new Map();
|
|
26
|
+
let next = 0, written = 0, timedOut = false, outputLimit = false, closed = false, elapsed = 0;
|
|
27
|
+
const stop = () => killGroup(child.pid, 'SIGKILL');
|
|
28
|
+
const send = value => { if (child.stdin.destroyed || closed) return false; child.stdin.write(`${JSON.stringify(value)}\n`); return true; };
|
|
29
|
+
const log = line => { written += line.length + 1; if (written > OUTPUT_LIMIT) { outputLimit = true; stop(); return; } fs.writeSync(fd, `${line}\n`); };
|
|
30
|
+
const failAll = reason => { for (const [id, entry] of pending) { pending.delete(id); clearTimeout(entry.timer); entry.reject(rpcError(reason, -32001)); } };
|
|
31
|
+
if (child.pid) {
|
|
32
|
+
const record = { pid: child.pid, identity: processIdentity(child.pid), bin: path.basename(bin) };
|
|
33
|
+
if (processFile) atomic(processFile, record);
|
|
34
|
+
}
|
|
35
|
+
// A held run (an approval waiting for the owner) does not spend its time limit.
|
|
36
|
+
const timer = timeoutSeconds ? setInterval(() => { if (hold?.()) return; elapsed += 1; if (elapsed >= timeoutSeconds) { timedOut = true; stop(); } }, 1000) : null;
|
|
37
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
38
|
+
if (signal?.aborted) stop();
|
|
39
|
+
for (const name of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(name, stop);
|
|
40
|
+
child.stdin.on('error', () => {});
|
|
41
|
+
const exit = new Promise(resolve => {
|
|
42
|
+
child.once('error', error => resolve({ code: null, error: error.message }));
|
|
43
|
+
child.once('close', (code, signalName) => resolve({ code, signal: signalName }));
|
|
44
|
+
}).then(result => {
|
|
45
|
+
closed = true; if (timer) clearInterval(timer); stop();
|
|
46
|
+
signal?.removeEventListener('abort', stop);
|
|
47
|
+
for (const name of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(name, stop);
|
|
48
|
+
if (processFile) atomic(processFile, { pid: null, identity: null });
|
|
49
|
+
fs.closeSync(fd);
|
|
50
|
+
failAll(`${path.basename(bin)} exited.`);
|
|
51
|
+
return { ...result, timedOut, outputLimit, stderrFile, log: logFile };
|
|
52
|
+
});
|
|
53
|
+
// Each server line: a reply to one of our requests, a request we must answer, or a notification.
|
|
54
|
+
async function receive(row) {
|
|
55
|
+
if (row.id !== undefined && row.method === undefined) {
|
|
56
|
+
const entry = pending.get(row.id); if (!entry) return;
|
|
57
|
+
pending.delete(row.id); clearTimeout(entry.timer);
|
|
58
|
+
if (row.error) entry.reject(rpcError(String(row.error.message ?? 'The vendor returned an error.').slice(0, 1000), Number.isInteger(row.error.code) ? row.error.code : -32000));
|
|
59
|
+
else entry.resolve(row.result);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (typeof row.method !== 'string') return;
|
|
63
|
+
const handler = handlers.get(row.method);
|
|
64
|
+
if (row.id === undefined) { if (handler) { try { await handler(row.params); } catch { /* A notification handler's failure is the handler's own. */ } } return; }
|
|
65
|
+
if (!handler) { send({ jsonrpc: '2.0', id: row.id, error: { code: -32601, message: 'Method not found' } }); return; }
|
|
66
|
+
try { send({ jsonrpc: '2.0', id: row.id, result: await handler(row.params) ?? null }); }
|
|
67
|
+
catch (error) { send({ jsonrpc: '2.0', id: row.id, error: { code: Number.isInteger(error.code) ? error.code : -32000, message: String(error.message ?? 'The handler failed.').slice(0, 1000) } }); }
|
|
68
|
+
}
|
|
69
|
+
const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
70
|
+
let chain = Promise.resolve();
|
|
71
|
+
lines.on('line', line => {
|
|
72
|
+
if (line.length > LINE_LIMIT) { outputLimit = true; stop(); return; }
|
|
73
|
+
log(line);
|
|
74
|
+
let row; try { row = JSON.parse(line); } catch { return; }
|
|
75
|
+
if (!row || typeof row !== 'object') return;
|
|
76
|
+
chain = chain.then(() => receive(row));
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
pid: child.pid, exit,
|
|
80
|
+
request(method, params = {}, { timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
81
|
+
if (typeof method !== 'string' || !method) refuse('A request names its method.');
|
|
82
|
+
if (closed) return Promise.reject(rpcError(`${path.basename(bin)} exited.`, -32001));
|
|
83
|
+
const id = ++next;
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const entry = { resolve, reject, timer: setTimeout(() => { if (pending.delete(id)) reject(rpcError(`No answer to ${method} within ${Math.round(timeoutMs / 1000)} seconds.`, -32002)); }, timeoutMs) };
|
|
86
|
+
pending.set(id, entry);
|
|
87
|
+
if (!send({ jsonrpc: '2.0', id, method, params })) { pending.delete(id); clearTimeout(entry.timer); reject(rpcError(`${path.basename(bin)} exited.`, -32001)); }
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
notify(method, params = {}) { if (typeof method !== 'string' || !method) refuse('A notification names its method.'); return send({ jsonrpc: '2.0', method, params }); },
|
|
91
|
+
on(method, handler) { if (typeof method !== 'string' || typeof handler !== 'function') refuse('on(method, handler) needs a method name and a function.'); handlers.set(method, handler); },
|
|
92
|
+
close() {
|
|
93
|
+
if (closed) return exit;
|
|
94
|
+
try { child.stdin.end(); } catch { /* Already closed. */ }
|
|
95
|
+
killGroup(child.pid, 'SIGTERM');
|
|
96
|
+
const force = setTimeout(stop, 250);
|
|
97
|
+
return exit.then(result => { clearTimeout(force); return result; });
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|