Omahub
← All plugins
L

Persona

by Luo Tao <luotao@hey.com>

Take the Open Extended Jungian Type Scales test from your bar: 32 questions fullscreen, one at a time, scored by openjung.org's public API. Keeps your four letters in the corner, with how rare they are among the tens of thousands of people in the project's open dataset.

Security review

No obvious issues detected

Deterministic scan — not a security guarantee

None
Risk level
None
Analyzed commit
12966e8
Scanned
1 month ago

No potentially dangerous behavior detected in the analyzed commit.

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

Low
AI risk level
Low
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
12966e8
Reviewed
1 month ago

The plugin is a well-engineered personality test widget that communicates only with openjung.org's public API, bounds all network responses, stores state privately with proper permissions, and includes extensive tests for hostile inputs. No malicious or dangerous behavior found; the code is transparent and security-conscious.

  • The plugin sends user answers to a third-party API (openjung.org) for scoring, but this is disclosed and the 'contribute' option is off by default.
  • It uses curl subprocesses, but the command construction is safe from injection and responses are size-bounded.
How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/ya-luotao/omarchy-persona --enable
Other #bar #quickshell

Persona

A Jungian type test in the Omarchy bar. Thirty-two pairs of opposites, one at a time, fullscreen; four letters in the corner afterwards, and how rare they are among the tens of thousands of people in openjung.org's open dataset.

The result page

The bar carries a mask and four letters; the panel and the fullscreen result page are in screenshots/.

The instrument is the Open Extended Jungian Type Scales (OEJTS), an openly licensed (CC BY-NC-SA 4.0) alternative to the commercial MBTI instruments. The questions, the scoring and the aggregate comparison all come from openjung.org's public API. This plugin is the interface, not the psychometrics.

Install

omarchy plugin add https://github.com/ya-luotao/omarchy-persona.git --enable

Nothing else is needed: curl and coreutils head are already on an Omarchy box, and they are the whole dependency list. xdg-open is used only by the optional "open this result on openjung.org" action.

Press the widget to open the panel, or bind the test directly:

omarchy-shell luotao.persona test     # start or resume the test
omarchy-shell luotao.persona toggle   # the panel

Middle-click the widget does the same as test.

Uninstall

omarchy plugin remove luotao.persona

That unregisters the plugin and removes it from the bar. Two things it does not touch, because they are yours:

rm -rf ~/.local/state/omarchy/persona    # your result, your history, the caches

That directory is 0700 and the files inside it 0600: the answers and the type they produced are nobody else's business, including other users of the same machine.

and any keybinding you added yourself, which lives in your own Hyprland config:

grep -rn "luotao.persona" ~/.config/hypr/

Nothing else is written anywhere. The plugin creates no files outside that one state directory and changes no configuration of yours.

Taking it

The test is fullscreen and keyboard-only:

key
1–5 answer, and move on
← / Backspace go back and change an answer
→ jump forward to the first unanswered question
Esc leave — everything answered so far is kept
r on the result page, take it again
s on the result page, open it on openjung.org

Which dimension a question measures is never shown. Knowing that a question is scoring J against P is exactly what makes people answer the type they would rather be.

Leaving halfway is fine. The bar shows 19/32 and reopening carries on from there; nothing is sent anywhere until all 32 are in.

Settings

key default
locale en question language: en, zh, ja, ko, zh-tw
showLetters true show the four letters next to the mask in the bar
contribute false add this result to openjung.org's anonymous research dataset

contribute is off by default and is the only setting that sends anything beyond the request needed to score your own test. With it on, the answers and the derived scores are stored by openjung.org for aggregate statistics — no name, no account, no address. It is the same dataset this plugin reads back to tell you how common your type is, which is the argument for turning it on and the reason it is not on already.

What it does not do

It never scores your answers itself. Every type shown by this plugin came back from POST /api/calculate. A half-finished test shows a count and nothing else — never an estimate, never a projected type.

That restraint is deliberate, and measured. OEJTS looks trivial to reimplement — eight questions a dimension, sum the answers — but openjung.org's own documentation disagrees with itself about the resulting range: /api-docs says 8–40 per dimension, /research/data says 0–48. Against the live endpoint it is 8–40 (all-1s returns 8, all-5s returns 40, all-3s returns 24 and lands on the left trait, matching the documented > 24 cut). But that is a measurement of today's server, not a promise about tomorrow's, and a plugin quietly disagreeing with the site it quotes is worse than one that has to wait for a reply.

So the arithmetic lives on their side, and this side checks their work: every response has its four scores compared against its own type letters, and a result whose numbers and letters disagree is refused rather than drawn.

What a bad day at openjung.org costs

The bar widget is keepLoaded: it lives as long as the session. So the question is not whether the happy path parses, but what a hostile, compromised or merely broken endpoint can do to a process nobody restarts without losing their desktop. Three answers, each measured rather than argued.

Bytes are bounded before this process holds them. QML's XMLHttpRequest works here — probe-net.qml measures it at ~2.5s per request against curl's ~1.2s, with no subprocess to pay for — and the plugin uses curl anyway, because responseText has no byte limit and is resident before any JavaScript can measure it. Instead every request is curl --proto "=https" --max-filesize <cap> --max-time 20 … | head -c <cap+1>, and every file read is head -c <cap+1>. The argv is built in Persona.js so it can be run in a test rather than described in a README.

Two independent over-cap signals, because measurement contradicted the documentation: --max-filesize is documented as having no effect when the length is not declared, but curl 8.21 does abort a chunked transfer once the received bytes pass the limit — leaving a body of exactly the cap, which no length check can tell from a legitimate response of that size. So the verdict reads curl's exit code as well, and head -c cap+1 remains the guarantee that does not depend on curl's behaviour at all.

Caps: 64 KiB for the question set and a result (21x and 46x their real size), 256 KiB for the research aggregate, 64 KiB for this plugin's own state file. A transfer is refused whole rather than truncated, so a cut-off response can never be parsed as a short one.

The answers never appear in argv. POST /api/calculate carries them on stdin (--data-binary @-). Argv is world-readable on this machine; a person's 32 answers are not something to publish to every other process on it.

The profile on disk is private, and the attacker is another process running as you. self.json holds the answers, the resulting type and a history of past types. Created with a plain mkdir and cat >, the usual 022 umask makes it 0644 — readable by every other local user on a machine with a traversable home. So the write sets the mode rather than inheriting it (umask 077, plus a chmod that repairs a directory or file left behind by an earlier version), and it is atomic: a temporary sibling and a rename, so a crash or a full disk mid-write costs the previous contents nothing.

0700 on the directory keeps other users out. It says nothing about another process running as the same user — a compromised application, or another plugin — and that is the process the rest of this has to survive:

  • The temporary sibling's name comes from mktemp, not from the target. Deriving it (self.json.new) made it an arbitrary-write primitive: a same-user process pre-places a symlink at a name it can predict, cat > follows it and truncates whatever it points at, the chmod applies to that file, and the rename tidies the link away. mktemp creates the file itself with O_CREAT|O_EXCL and mode 0600, so there is no name to pre-place.
  • The directory is checked for being a symlink before anything is done to it, and again after mkdir -p, because chmod follows one and the parent (~/.local/state/omarchy) is shared with every other Omarchy plugin. The startup repair skips symlinked files for the same reason.
  • Every read refuses a symlink, requires a regular file, and carries a deadline. A FIFO planted at a cache path blocks head forever on open, and the reader belongs to a shell that lives as long as the session: the state would never load and the process would never be reaped. The gate and the open are two syscalls with a gap between them, so the deadline is there as well rather than instead.

Every sink is plain text. QML's default Text.AutoText reads a value beginning with markup as rich text, which means a trait containing <b> would be styled and one containing <img src=…> would make the shell fetch it. Everything on both pages comes from openjung.org, so every Text is Text.PlainText, and the three strings handed to components whose Text this plugin does not own — a bar tooltip, a panel hero — are stripped of markup delimiters first. wrapMode is Text.Wrap, not Text.WordWrap: word wrapping cannot break an unbroken run, and a 2,000-character one painted straight out of the panel until it was.

Post-parse bounds are a second, separate defence, because a response can sit inside the transport cap and still carry a 60 KB nickname: traits, names and list items are cut at 400 characters, the description at 2,000, lists at 12 items, and the compatible-types row keeps only strings that really are type codes.

tests/hostile.js runs the real generated argv against a local HTTPS server built to break it — a 20 MiB chunked flood, a declared 20 MiB length, a stalled transfer, a body that tries to forge curl's own status marker, an untrusted certificate, a plain-http base, a file:// base, and a bloated-but-legal response. It speaks HTTPS with a throwaway CA rather than testing a weakened command.

The comparison numbers

/api/research/stats publishes a type distribution and, per dimension, six histogram buckets over all recorded tests. Your score is placed by bucket membership — "band 19-24, with 17% of respondents" — and deliberately not as a percentile: six buckets do not support interpolating a position inside one.

The aggregate is cached for a day, and the widget never fetches on shell start — only once you open the panel. The 32 questions are cached per language on first fetch, so after that the test itself works with no network at all. Only scoring needs the server.

Two bugs in the API, worked around

Both were found by running it, and both are re-checked by tests/live.js so the workaround can be removed if the API is fixed.

  • The share URL 404s for English. /api/calculate returns https://openjung.org/en/result/ESFJ-21-17-19-15, which does not resolve; the site serves the default locale at the bare path (/result/…, 200) and other locales under their prefix (/zh/result/…, 200). Persona.shareUrl() drops the /en segment and leaves every other locale alone.
  • /research/data documents a 0–48 score range. The API returns 8–40. The published histogram buckets (0-12 … 37-48) are simply wider than the real range at both ends, so bucket membership still works; nothing else relies on the documented range.

Layout

Persona.js the model: state on disk, walking a sitting, reading responses, placing a score. Pure JS, no Qt — and it cannot score.
Backend.qml the whole I/O surface: three GETs, one POST, one small file.
Transfer.qml one bounded request, and the verdict on it. Waits for both streams, because the shell's exit status is head's and is always 0.
BarWidget.qml the mask, the letters, and the panel.
TestOverlay.qml the fullscreen test and the result page.
probe-net.qml does this QML engine have XMLHttpRequest, and what does it cost against curl?
probe-backend.qml Backend.qml end to end against the live API.

State lives in ~/.local/state/omarchy/persona/: self.json (the sitting, the last result, a short history of past types) and the two caches. Deleting it resets everything.

Tests

mise x node@24 -- node tests/exports.js    # every top-level name is exported
mise x node@24 -- node tests/persona.js    # 642 checks against captured fixtures
mise x node@24 -- node tests/hostile.js    #  96 checks against a hostile server
mise x node@24 -- node tests/live.js       #  53 checks: is this still the API?
qs -p probe-net.qml                        # what transports exist, and what they cost
PERSONA_STATE=/tmp/persona-probe qs -p probe-backend.qml

tests/persona.js and tests/hostile.js are offline: the first runs against responses captured from the real API in tests/fixtures/, the second against a local HTTPS server (needs openssl and curl; makes no outbound connections).

tests/live.js needs the network and hits a free service somebody else pays for — it is not part of the default run, it sends save: false on every request, and it exists to fail loudly the day the endpoint changes shape. It also runs the plugin's own bounded command against the real endpoint, because the shell never uses node's fetch and a command that works only against the test server would pass everything else.

Credit and licence

Type indicators are not diagnostic instruments, and OEJTS does not claim to be one. Four letters are a conversation starter, not a description of a person.