feat: shell completions, with the ids and names of the jobs - #3
Conversation
49a496f to
ea9a741
Compare
Before the merge
What the verification foundThe branch said that the zsh and fish parts were written and not run. zsh 5.8.1
Three more:
What was driven, and howEach shell loaded the completions and a real TAB was pressed: bash 5.1 and zsh The hostile names. 22 job names went into a scratch queue: What is not covered
|
There was a problem hiding this comment.
Pull request overview
This PR introduces first-class shell completion support for qex, including dynamic TAB completions for job ids and job names by querying qex __complete at completion time (without starting a coordinator). It also updates documentation and adds end-to-end coverage for the new completion behavior and safety constraints.
Changes:
- Add
qex completions <shell>(generates scripts viaclap_complete) and hiddenqex __complete <what>(prints job candidates from on-disk records). - Inject dynamic job completion logic for bash/zsh/fish, including filtering hidden subcommands out of generated completion output.
- Add e2e tests that validate shell script contents and that “real bash” completion does not execute malicious job names; update docs/CONTRIBUTING accordingly.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/e2e.rs | Adds e2e tests for completion correctness, safety, and coordinator non-start behavior. |
| src/main.rs | Implements completions generation and dynamic completion injection/removal of hidden commands. |
| src/commands.rs | Adds hidden __complete command to print completion candidates from disk. |
| src/cli.rs | Adds CLI plumbing for completions and __complete. |
| README.md | Documents how to install shell completions. |
| docs/reference.md | Adds completions docs and command synopsis entry. |
| CONTRIBUTING.md | Adds guidance to work on branches + open PRs. |
| Cargo.toml | Adds clap_complete dependency. |
| Cargo.lock | Locks clap_complete and updates dependency graph. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # The word before is an option that takes a value, so this word is that | ||
| # value and not a job: `qex kill --signal <TAB>` must not offer a job. | ||
| local prev="${{COMP_WORDS[COMP_CWORD-1]}}" | ||
| case "$prev" in -*) return 0 ;; esac | ||
|
|
| let mut seen = std::collections::BTreeSet::new(); | ||
| for job in out { | ||
| println!("{}", job.id); | ||
| // One candidate on each line, so a name that holds a line break or a | ||
| // control character goes out. Such a name would become two candidates, | ||
| // and neither of the two would be a job. The name still operates in | ||
| // every other command; the shell alone cannot offer it. | ||
| let usable = !job.name.is_empty() && !job.name.chars().any(|c| c.is_control()); | ||
| if usable && seen.insert(job.name.clone()) { | ||
| println!("{}", job.name); | ||
| } | ||
| } |
| # The ids and the names of the jobs. `qex __complete` reads the records on the | ||
| # disk, so a press of TAB starts no coordinator. | ||
| complete -c qex -n "__fish_seen_subcommand_from {READS}" -f -a "(qex __complete ids)" | ||
| complete -c qex -n "__fish_seen_subcommand_from {ACTIVE}" -f -a "(qex __complete active)" | ||
| complete -c qex -n "__fish_seen_subcommand_from {QUEUED}" -f -a "(qex __complete queued)" |
| qex info the coordinator: its pid, its budget and its load | ||
| qex config show the values that qex uses now | ||
| qex schema job|status the JSON Schema of each format | ||
| qex completions <shell> the completions for bash, zsh or fish |
b618f34 to
af2a0d6
Compare
The six findingsFinding 3 first, and the review is right. My reason for rejecting Measured on this branch, in a real bash, with TAB and then ENTER, and the name
Finding 1 (a directory of the same name) and finding 2 (a leading Finding 4. Finding 5. The guard read every word that starts with a dash. It now names Measured: Finding 6. The sentence about the line break. Corrected. The code drops every control Mutation testing, run again22 mutations, 21 caught. Two that the review added are now caught:
Also caught: going back to One declared survivor stays: The numbers193 unit tests, 89 end-to-end tests, |
af2a0d6 to
b8d5c7b
Compare
The sanitised name, and the second reviewThe rule, and one measurement that changed it
The instruction said "replace each character outside the set with A second correction to the instruction, also by measurement: Where it is appliedOne function at the output boundaries. Measured on a real queue: the record on the disk holds The collision falls through cleanly, with no new error: Not changed, and I want this seen rather than assumed: The second reviewFinding 1 is correct and it is now fixed. The false sentence is gone from the code comment and from the pull request body. The root cause is closed. A new test sources the file in bash, runs Finding 2. Both stale comments are gone. Finding 3. The ESC case is asserted on the BYTES, in Finding 4. Both body edits are made. The note about The quoting is still under testSanitising means the real Mutation testing29 mutations, 28 caught. The survivor is the declared one: New this round and caught: the version gate; the registration binding; each part The numbers193 unit tests, 91 end-to-end tests, |
Squashed for the rebase onto main.
A leading
|
b8d5c7b to
920dd85
Compare
qex offered no completions.
qex completions <shell>now writes them for bash,zsh, fish, elvish and powershell.
The valuable part is the jobs
A job id is a uuid, and nobody types a uuid. After
qex status,qex wait,qex logs,qex rerun,qex clean,qex killandqex cancel, the shell now offers everyjob by its id AND by its name.
Each command offers the set that it accepts, and no other:
A candidate that the command would refuse teaches the wrong command.
This matters to a person who drives an agent from a terminal. The agent writes
the full command and needs no completion, but the person who watches the work,
stops a job or reads a log types the ids by hand. Those ids are uuids that the
agent chose.
The ids cannot come from the definition of the command line
They change with each job, so the shell asks qex at the moment of the TAB, with
a hidden command:
qex __complete.That command never starts a coordinator. It reads the records on the disk.
qex listwould start one, and a press of TAB is not a request to start aprocess: a user who pressed TAB in a directory with no work would leave a
coordinator behind. A test holds that rule.
qex SHOWS a safe form of each name
This is a change of behaviour, and a reader with old records sees it. A job
that you named
deploy prodis shown asdeploy_prod.qex now shows a name that holds the letters
AtoZandatoz, thenumbers
0to9, and the three characters-,_and.. Every othercharacter becomes
_, a run of them becomes ONE_, a first character of-becomes
_, and the result stops at 128 characters.This is at EVERY output:
qex list,qex status,qex top,qex du,qex gc, the sentence that says why a job waits, the sentence that says which jobfailed, the sentence that says that a record is gone, the log of the
coordinator, the completions, and the JSON of each of them. The JSON holds the
safe form as well, because a machine that reads it renders what qex gives it and
it knows no more than qex does.
The record on the disk keeps the name that you gave. qex changes no record,
and
qex gcis not the thing that applies the rule: the safe form comes fromthe name in the record, so the rule reaches every record at once.
A safe name goes back into a command as it stands.
resolve_idfinds a jobby its safe name and by its stored name, so you copy a name out of
qex listand paste it into
qex status. Two names that give one safe form make that wordname more than one job, and qex gives the error that it already gives for such a
word: it lists the jobs and it asks for an id.
Why. A name is text that another agent chose. A name that holds an ESC byte,
written to a terminal by
qex list, moves the cursor and writes over the textaround it. No shell and no TAB are needed for that, so the answer belongs at
each output and not at one.
A job name must not run when somebody presses TAB
An agent chooses the names of the jobs, and a person presses the TAB, so a name
is text that an attacker can write.
Each shell puts the word on the line as ONE word. A candidate that holds
build; rm -rf ~thus becomes one argument of qex, and never a command. bashuses
printf %q; zsh and fish make the word safe themselves.The safe form above is not a reason to stop doing that. The answer of
qex __completeis text that came off a disk, and a disk is not a guarantee: arecord that another program wrote, a record of a qex that is older or newer, and
a fault in the sanitiser all reach the shell in the same way. A test puts a
stand-in
qexin front of the real one and answers with 19 hostile names, andit requires that each arrives as ONE argument that is the name itself.
The candidates also go into the reply one line at a time.
compgen -Wexpandsits word list AGAIN, and a job named
$(...)ran when somebody pressed TAB.compopt -o filenamesasks bash to do the quoting instead, and this work used itfirst. bash then treats each name as a FILE NAME: a job with the name of a
directory got a
/after it, a name of$HOMEbecame a directory as well, and aname that starts with
~was expanded to a home directory. Each of the threegave qex a name that no job has.
printf %qhas none of that behaviour, and itwas measured on bash 5.1.16 and on bash 3.2.57.
One behaviour remains, in fish only. fish reads a leading
~as your homedirectory when you press ENTER.
docs/reference.mdsays so, and it gives theremedy.
The hidden commands stay hidden
#[command(hide = true)]keeps a command out of the help text, and it does NOTkeep it out of the completions. A person who pressed TAB was offered
qex daemon— a command that starts a coordinator in the foreground — besideqex submit.qex daemon,qex superviseandqex __completenow go out of thecompletions of every shell.
What each shell gets
bash, zsh and fish get the commands, the options, and the jobs.
elvish and powershell get the commands and the options only. qex does not
test them, and a completion that nobody tested teaches a value that may not
exist.
Tests
Three tests.
The completions name the real commands, they hold no hidden command, and
each shell asks qex for the correct set. The zsh test reads the job argument
of each command, because an earlier form of this work put the zsh part after
the line that gives the completion to the shell: the words were in the file
and zsh never ran them.
The candidates come from the disk and start no coordinator, and a name that
holds a line break is not offered.
A REAL bash sources the completions and calls the completion function.
A fourth test sources the file and reads what
complete -p qexholds, whichis the one thing the others cannot see. It gives the correct
jobs for
qex status,qex kill,qex cancelandqex clean; it gives nojob for the value of
--signaland it still gives one after the flag--json; it does not offer a job named--versionwhere an option goes;and it keeps a name that holds a space in one piece. It also gives a job the
name
bait$(touch FILE), reads the completed word back the way the commandline reads it, and requires one argument and no file.
A stand-in
qexanswers with 19 hostile names, and bash must give eachas ONE argument that is the name itself, and run nothing.
Every output shows the safe name, the record on the disk keeps the stored
name, the round trip through
qex listworks, and no output writes the bytesof a name that holds an ESC byte.
Also here
CONTRIBUTING.mdnow says that a change goes on a branch and that a pullrequest merges it, with the commands to do so.
-o nosortcame with bash 4.4, andcompleterefuses the whole command when itmeets an option name that it does not know. The registration now tests the
version of bash, in the way that clap tests it for its own line. Without that,
sourcing the file on the bash 3.2 of macOS bound NOTHING and wrote an error at
each shell start.
Not changed:
qex pipelineechoes the names of the stages of the file thatyou gave it, and those names keep their form. A stage name is your own input in
the same command, and it is a key of the mapping that
--jsonand the id filegive to a machine. The job that a stage becomes is a job like any other, and
every command shows its safe name.