Desktop setup protocol¶
python -I -B -m insto.desktop accepts one UTF-8 JSON line on stdin, followed
by EOF, and emits one JSON line. The caller owns the process timeout and must
close stdin after the request. There is no network listener or shell transport.
This private API supports desktop profile setup and macOS service management. It is not an app installer or a complete GUI. Account management and monitoring history are separate delivery stages; these operations do not add watches.
Request and response¶
The request has exactly protocol_version, request_id, operation, and
params. Version is integer 1, not a boolean. IDs contain 1–64 ASCII letters,
digits, underscores or hyphens. Input is limited to 64 KiB, output to 2 MiB,
including the terminating newline. Duplicate JSON keys and nonfinite numbers
are rejected. JSON must occupy a single line.
hello requires empty params. Its result includes core_version,
schema_version_supported, and the capabilities below. Clients should check
capabilities, not assume every operation exists merely because protocol major
version 1 is supported. The handshake does not load credentials, open a database
or construct a provider.
Responses carry protocol_version and request_id, plus either result or
error {code, message, retryable}. Error messages are static and never echo
request values or exception text. Invalid requests have a null request ID;
unsupported protocol versions preserve a validated ID. Once dispatch starts,
errors preserve the decoded ID. retryable is advisory: after a transport
timeout or interrupted mutation, inspect state before submitting another change.
Operations¶
| Operation | Exact params | Effect |
|---|---|---|
hello |
{} |
Version, supported database schema, implemented capabilities. |
setup.inspect, settings.inspect |
{} |
Read profile, pending recovery, cached quota and observed service state. |
setup.configure |
{"token":"..."} |
Validate and save first credentials; initialize the database and service. |
credentials.replace |
{"token":"..."} |
Validate a candidate and replace credentials with durable rollback. |
service.start |
{} |
Persist running intent and start the owned service. |
service.stop |
{} |
Persist stopped intent, disable and unload; retain config, database and service files. |
service.repair |
{} |
Reconcile pending recovery, or bring an owned service to its saved intent. |
Tokens contain 4–4096 ASCII characters in the range 33–126: no spaces or control characters. The minimum matches the redactor's secret registration floor. Extra keys, including paths, backend selection and native arguments, are rejected before profile or operation loading. There is no production fake-provider option.
Setup/settings and successful mutation results contain configured, status,
desired_service, service_running, quota_remaining, quota_checked_at, and
revision. Unconfigured fields are null where appropriate. Status is one of
unconfigured, recovery_required, quota_exhausted, running, stopped, or
service_error. Ownership/schema failures use an error response rather than an
apparently healthy empty profile. Tokens, token fingerprints, raw native output,
paths and exception details never appear in these results.
Quota is the last explicit credential validation result, with a Unix-seconds
timestamp, not a live balance. service_running describes the matching native
process/executor lock, not end-to-end monitoring health. Zero quota can be saved
but always reports quota_exhausted, even with a running local service.
Read operations do not initialize directories, migrate databases, change service intent, construct providers or make provider requests. Existing WAL-backed schema checks use a private disposable snapshot to avoid changing source sidecars; concurrent database changes fail closed and may require a fresh inspection.
Errors¶
| Code | Meaning |
|---|---|
invalid_request |
Invalid JSON, envelope, field type, ID or input budget. |
unsupported_protocol |
A different integer protocol version was requested. |
unsupported_operation |
The operation is not implemented. |
invalid_params |
The operation received unsupported parameters. |
internal_error |
The operation or response serialization failed. |
invalid_token |
Provider rejected the candidate credentials. |
quota_exhausted |
Provider rejected access due to exhausted quota. |
rate_limited, network_error, access_unconfirmed |
Validation did not establish access; no candidate is saved. |
operation_timeout |
The budget expired; inspect before retrying. |
profile_busy |
Another profile operation holds the lock. |
profile_ownership |
Profile paths, permissions or ownership cannot be trusted. |
not_configured, already_configured |
Setup state does not match the requested operation. |
recovery_required |
A protected incomplete transition needs reconciliation. |
service_error, storage_error, schema_mismatch |
Service, private storage or database preflight failed. |
unsupported_platform |
Desktop service management requires macOS. |
home_invalid |
The path, its ownership or its contents cannot be used as a profile home. |
home_backend_unsupported |
The home's backend is not hikerapi. |
service_ownership_unknown |
Manifest, plist or job ownership cannot be proven; mutations refuse. |
service_config_mismatch |
The registration's operational pins differ from the home's configuration; migrate refuses. |
profile_busy, rate_limited, network_error and access_unconfirmed are marked
retryable. Other errors are not. No exception text is forwarded to the client.
A protocol error is a JSON envelope with exit 0. A nonzero exit, malformed
stdout or caller timeout is a transport failure, not a successful operation.
The caller must independently bound stdout/stderr and process lifetime; a
10-second deadline is sufficient for hello. Credential operations have one
120-second composite budget including validation, with 35 seconds reserved for
rollback. Provider validation and close share a maximum 30-second wait. Started
native workers drain before management/profile locks release. Cancellation-
resistant third-party tasks or synchronous blocking still require the caller's
hard process deadline; a JSON error alone is not proof that the child exited.
Child stderr is not a UI message.
If a kickstart client times out, the controller does not repeat the command: it observes the owned service within the remaining budget. Only verified native running state and the matching busy executor lock can turn that uncertain result into success. Startup/termination transitions alone are never readiness.
Profile ownership and recovery¶
The trusted launcher supplies an absolute canonical INSTO_DESKTOP_ROOT, or
the default is ~/Library/Application Support/insto-gui. The fixed profile/
child belongs only to this desktop installation. The application root's parent
must already exist. JSON cannot select another home, and INSTO_HOME is ignored.
External CLI-home adoption and interpreter migration are not repair operations.
The application root/profile are private (0700); config, ownership state, journal, backup and locks are private owned ordinary files (0600). Symlink, hardlink, foreign-ownership and unsafe writable-ancestor cases are refused. An unmarked populated profile is not adopted. Atomic publication stages are equally private and may remain in the application root after a crash; they are never treated as committed credentials or recovery authority. Do not manually rename them into the profile. The token is stored in private TOML, not Keychain in this stage.
First setup publishes a journal, validated config and bound state before service startup. A startup failure retains the validated config so Repair can finish local setup without another provider request. Repeating the same token validates it again but does not unnecessarily restart the service; a different token must use credential replacement.
Replacement validates before disk/service changes, then holds the profile lock and service management lock. It durably saves the previous config and journal, confirms the service is stopped, and holds the idle executor lock while publishing credentials. Only a previously running service restarts; a stopped or already cleanly exited service stays nonrunning. Failure/crash recovery stops any candidate before restoring the exact old config/state, and never restores beneath an unconfirmed executor. Missing required backup data cannot become successful recovery.
Terminal committed/rolled_back records only finish cleanup. Repair with pending
recovery reports the reconciled state; explicit Start is a separate action. With
a nonterminal journal, Stop returns recovery_required before native work rather
than starting a service as a recovery side effect: repair first, then stop.
Reopening the client never changes persisted stopped intent. Ordinary Start/Repair
can kickstart an exactly owned loaded-but-cleanly-exited job without forced restart.
Strict HikerAPI access primitive¶
HikerBackend.validate_access() is backend-only. It uses the existing HTTP
transport and retry policy to check /sys/balance, returning a Quota with a
nonnegative integer remaining. Zero confirms an exhausted balance, not a
healthy monitor. Invalid credentials, quota/rate limits, temporary failures and
unknown response schemas remain errors. Non-success HTTP responses, including
redirects with balance-shaped JSON, never confirm access. Balance 403/404 are unconfirmed access,
not Instagram profile banned/not-found results.
The existing soft refresh_quota() behavior is unchanged. Desktop validation
registers a candidate before client construction, uses the packaged provider host
and an HTTP transport with environment lookup disabled, and attempts close once
on every constructed-client outcome. Inherited provider token/host/backend and
proxy/CA settings do not override that explicit validation client.
Service compatibility¶
Installations initiated by an interpreter with bytecode writes disabled preserve
that setting in their LaunchAgent: python -I -B -m
insto.service.watch_service_runner <manifest>. Ordinary CLI installations retain
the legacy launch form without -B.
An existing registration must match the requested form exactly for installation to be a no-op. Switching interpreter mode does not silently replace its plist. Removal accepts either exact owned form, regardless of the uninstalling interpreter's bytecode setting; other ownership checks remain in force.
This foundation neither bundles Python nor installs an app. Portable-runtime and installed-app verification are separate delivery stages.
C2a local watch operations¶
The original eight C1 capabilities remain supported. New capabilities are overview, watches.list, watches.add, watches.update, watches.pause, watches.resume and watches.remove. They never validate a token or contact HikerAPI.
overview takes empty params and returns configured, desired_service, service_state (running/stopped/unknown), quota_remaining, quota_checked_at, watches and next_cursor. The quota fields are the saved balance and its saved check time (UTC Unix epoch seconds), not a live provider balance. Both are null before configuration. Display them as the last confirmed balance; a local overview refresh does not update either value or call the provider. It reads at most one watch page and inspects the owned service after closing the SQLite transaction. Unknown service state is not treated as stopped or healthy.
watches.list accepts optional limit (integer1..50, default50) and cursor. It returns items and next_cursor. Three is the active-watch cap, not the total-list limit. Following cursors does not promise a database snapshot across requests.
watches.add accepts user and optional interval_seconds (default300) and only creates an absent watch. Existing paused watches require explicit resume. update requires user/revision/interval_seconds. pause, resume and remove require user/revision. Input usernames are canonicalized and bounded to255 ASCII characters; intervals are integer seconds300..2147483647, never booleans/floats.
A watch DTO contains user, status, interval_seconds, last_ok (UTC epoch seconds or null), waiting_first_check, has_error, consecutive_errors and an opaque revision. It contains neither the internal generation identifier nor raw historical errors. Mutation returns watch, except remove returns removed_user and preserves snapshots.
Stale revisions fail as watch_conflict. A missing row gives watch_not_found, duplicate add watch_exists, and exceeding three active rows watch_limit. No-op state/interval commands keep revision. Real state/interval changes rotate generation and fence late daemon status writes in the same SQLite transaction. Pausing cannot undo a snapshot already committed by a running tick.
Read operations never initialize profiles, take a profile lease, change config, migrate schema, copy all history or invoke service mutations. SQLite may maintain normal private WAL/SHM files. WAL can contain committed data and is never manually deleted or ignored by the reader. C1 check_database retains its stricter source-file behavior. Busy/recovery/ownership/schema failures remain explicit.
Local read and mutation budgets are10seconds, including validation, SQLite and DTO work. Busy waits are at most1second. Timeout or transport loss after a mutation requires reading current state; do not automatically retry a non-idempotent add.
C2b saved history operations¶
The additional capabilities are snapshots.targets, snapshots.list,
snapshots.compare and changes.list. They inspect saved SQLite snapshots only.
They never construct a provider, call live /diff or command /history, start a
scheduler, download historical media, change a watch, or prune the database.
Exact parameters:
| Operation | Required | Optional |
|---|---|---|
snapshots.targets |
username |
limit, cursor |
snapshots.list |
target_pk |
limit, cursor |
snapshots.compare |
target_pk, older_id, newer_id |
none |
changes.list |
none | target_pk, limit, cursor |
Username accepts one optional leading @, followed by 1–255 ASCII letters,
digits, periods or underscores; . and .. and whitespace are rejected.
It is normalized to lowercase without @. This matches C2a's protocol storage
bound and does not assert provider existence. G1 passes the canonical username
returned by watch operations; history itself rejects raw whitespace. target_pk is a canonical positive
decimal string of at most 64 digits. Snapshot IDs are canonical positive decimal
strings through 9223372036854775807; JSON numeric IDs and leading zeroes are
rejected. limit is an actual integer from 1 through 50, default 50. Optional
keys are omitted when unused, not null. Extra keys and invalid cursor bindings
return invalid_params before profile or history loading.
Pages contain items, next_cursor, scan_complete, and scanned. Cursors are
opaque, canonical, unpadded base64url strings of at most 1,024 characters. They
bind protocol-internal cursor version, operation, normalized filter, initial
maximum snapshot ID, and descending (captured_at,id) frontier. A caller may
change the page limit while following a cursor, but may not change its filter
or operation. New normal AUTOINCREMENT rows are excluded from that traversal,
including rows inserted with an older timestamp. Retention can remove rows
between requests. An exact-limit page can conservatively have a cursor whose
next page is empty and complete.
Snapshot metadata is {id,target_pk,captured_at}. id and target_pk are always
strings. captured_at is UTC Unix epoch seconds, from 0 through 253402300799;
the GUI renders local time. Every operation orders by (captured_at,id), with
newest first for pages and increasing order for an explicitly selected pair.
snapshots.targets canonicalizes username exactly like watches.add and the
CLI, in the same order: leading @ characters are removed first, then surrounding
whitespace, then the value is lowercased before the 255-character bound applies,
so one GUI field means one thing everywhere (a space before @ stays invalid). It returns {kind:"target",target_pk,snapshot} historical
evidence from saved username values. Deduplication is page-local. The caller
unions PKs across all pages and retains warnings. One result before
scan_complete never establishes unique identity. Even after completion,
diagnostic rows mean the evidence is incomplete: no exhaustive zero/one-PK
claim is permitted. A missing/null old username produces the result-local
diagnostic history_identity_unknown. Multiple PKs require the user to select
the desired saved history; a newest match is never chosen automatically.
Renaming an account does not rename its watch registration.
snapshots.list returns {kind:"snapshot",snapshot} metadata only, or a safe
diagnostic for an unreadable saved record. Profile fields are checked within
the raw byte cap but are not included in list items. An empty list means no
retained snapshots for that PK; one snapshot is an initial retained baseline.
snapshots.compare returns {kind:"comparison",older,newer,changes,unknown_fields}.
changes contains {field,old,new} for known values that differ. The field set
is the existing tracked profile fields plus avatar and banner stored hashes.
Absent fields are listed in unknown_fields; explicit JSON null is a known
value. Missing selected IDs return snapshot_unavailable, prompting a list
refresh. A different PK returns snapshot_identity_mismatch; reversed/equal
pair ordering returns invalid_params. The API does not manufacture a prior
snapshot from null.
changes.list compares each candidate with its immediately preceding retained
snapshot within the same PK and initial ID ceiling. Its earliest retained
snapshot is {kind:"baseline",snapshot}. Fully known unchanged comparisons
are omitted. A comparison with any unknown field has kind:"incomplete" and
the same older,newer,changes,unknown_fields shape. Other changed pairs have
kind:"comparison". These are observations between capture times, not exact
Instagram event times; follower counts do not identify individual followers.
Paginated JSON failures appear as {kind:"diagnostic",snapshot,code} with
history_corrupt or history_oversized, and the cursor can advance past them.
Invalid identity/order metadata fails the operation with history_corrupt
because a safe continuation cannot be constructed. Pair-read JSON failures
are static errors with the same code. Raw JSON and exception text are never
included in diagnostics. A malformed snapshot is not silently an empty result.
Each row's two raw JSON columns together may occupy at most 65,536 UTF-8 bytes.
SQL uses length(CAST(... AS BLOB)) and CASE projection to suppress oversized
columns before they reach Python. Duplicate keys, NaN/infinity, wrong scalar
types, invalid identifiers and count coercions are rejected. Batches contain
at most 16 raw rows. The feed selects at most 200 candidates per request plus
one predecessor lookup per inspected candidate; username discovery selects at
most 2,000 candidates. scanned counts inspected candidates, including a row
deferred by the byte budget; the cursor advances only through completed rows.
An unchanged feed page can have no visible items and still have continuation.
No page contains more than 50 visible items.
All local reads share a 10-second deadline started before parameter validation,
with SQLite busy timeout at most one second and a progress handler. Decoding,
comparison and encoding also check that deadline. Timeout discards a partial
page and returns operation_timeout. The deadline covers reading, assembling and
byte-budgeting the page; the transport's final serialization of an already
complete result is bounded by the same 2 MiB budget and is deliberately not
interrupted, so a response may arrive a few milliseconds after the deadline but
is never partial. Bounded pages reserve worst-case envelope
and cursor bytes and count actual ASCII JSON escaping; the entire response,
including request ID and newline, is strictly below 2 MiB. A full byte budget
shortens the page with continuation instead of becoming internal_error.
C2 uses its separate trusted mode=ro, query_only, explicit-read-transaction
path with schema validation in the same transaction. It never creates profile
directories or application locks, migrates schema, calls C1 check_database,
changes settings, or repairs ownership. SQLite may normally maintain its own
private WAL/SHM files. WAL can contain committed data and is never deleted or
ignored. Sidecars do not prove a daemon is running. Main-file byte invariance
is tested without a concurrent writer; concurrent tests check consistency and
transaction release. C1 setup/settings checks keep their existing separate
copying and byte-invariance behavior.
The four added static error codes are history_corrupt, history_oversized,
snapshot_unavailable, and snapshot_identity_mismatch, all non-retryable
without a user decision or refreshed selection. SQLite contention propagates
the shared reader's profile_busy; ownership/config/schema/recovery failures
remain explicit. The existing retention stays 30 days and at most 100 snapshots
per PK; the count cap can shorten that period. There is no event archive, cache,
new schema version or additional index in C2b.
C3 migration and adoption¶
Capabilities become the 19 C2 names plus service.inspect, service.migrate, service.uninstall, home.inspect and home.select (24). The core is insto 0.7.22 or later.
| Operation | Exact params | Budget | Effect |
|---|---|---|---|
service.inspect |
{} |
read, 10 s | Registration facts for the current profile: registration (none/owned/unknown), interpreter (current/other/null), interpreter_exists, loaded, settings (matching/different/null: whether the registered pins equal what this interpreter would register for the home's configuration). Reported even before the desktop has intent for the home (settings null without a parseable config); without state and without registration files everything is none/null. Never changes files or jobs. |
service.migrate |
{} |
mutation, 120 s | Rewrite an owned registration into exactly the form this interpreter would register (interpreter and launch form), with rollback; no-op when the registration already is that form, or nothing is registered. Returns the profile DTO. |
service.uninstall |
{} |
mutation, 120 s | Persist stopped intent, then remove the exact owned registration (plist first, then manifest) once the job is unloaded and no executor runs; config, database, state and history are kept. Needs no credentials or database. |
home.inspect |
{"path":"..."} |
read, 10 s | Read-only report on an external home (below). Never creates, changes or locks anything. |
home.select |
{"path":"..."} or {"path":null} |
mutation, 120 s | Bind the desktop to that home, or back to its own profile. Returns the profile DTO of the new binding. |
The desktop root may contain desktop-home.json (schema_version 1,
managed_by, uid, absolute canonical home). While it exists, the profile
is that home: config is its config.toml, the database is the one its config
resolves to, desktop intent is home/desktop-state.json, and recovery files
keep their names inside it. The desktop never migrates or chmods an adopted
directory and never touches its other files; home.select creates only
desktop-state.json (quota fields null, desired_service running when the
home's registration is loaded and running), .desktop.lock (the home's own
lease, so two desktop roots never write one home at once), a missing database
with the current schema (published without replacing a database another
writer, such as the CLI, created meanwhile: that one is kept and its schema
decides), and the services/watch directories the service controller needs
(the same ones the CLI's install creates). An adopted home must sit under
trusted parents (owned by root or the user, not world-writable unless
sticky), exactly like the desktop root. The own profile of any desktop root
(a directory whose parent carries desktop-state.json, desktop-home.json
or .desktop.lock, a root mid-setup included) is never adoptable: two roots
sharing it would fork intent across different locks and state files, so
home.inspect reports it private but adoptable false with reason
home_invalid, and home.select refuses it. Relative paths in the home's
config resolve against the home, the service's working directory; an absolute
db_path outside the home is honoured (the CLI's own semantics), its file
must pass the same private-file checks as any profile database, and it must
sit in an existing owned private directory under trusted parents, exactly
like the home — otherwise the config is invalid on inspection and
home_invalid on every later open. Inside the home, a database whose parent
directory is missing or not private is unreadable (reason storage_error),
because selection would stage the database beside it.
Configured adopted profiles report null quota fields until the next credential
validation; quota_exhausted applies only to a saved zero.
home.inspect returns path, exists, private, config (ok/missing/
invalid), backend (hikerapi/aiograpi/fake/null), database (ok/missing/
schema_mismatch/unreadable), registration, interpreter, loaded,
process (running/stopped/unknown), adoptable and reason (a static error
code or null). adoptable requires a private owned real directory, config
ok, backend hikerapi and database ok or missing. Nothing inside a
non-private path is read: such a report says invalid, unreadable and unknown.
Paths are absolute or ~/~/… (this account's home, expanded by the core),
at most 1024 UTF-8 bytes, no NUL, normalized and free of symlinks; anything
else is home_invalid (invalid_params for a wrong type, an empty, oversized
or NUL-containing value) before any read. The config file must be exactly what
the profile reader accepts later (regular, owned, mode 0600, one link),
otherwise config is invalid. Tokens never appear in the report.
home.select refuses recovery_required while the current profile has a
pending journal or backup (re-checked under the home's own lease, so a journal
another root wrote meanwhile is never discarded), and is a no-op for the
current binding; re-selecting the bound home recreates a deleted
desktop-state.json from a fresh inspection. Before
switching away from the own profile it stops the desktop-owned service and
persists stopped intent; an adopted home's service is never touched by
selection, so a CLI-registered service keeps running until service.migrate
takes it over. Returning to the own profile never starts its service.
service.migrate is a journaled transaction (kind migrate): both sides
(previous and candidate bytes) are retained in one durable document beside
the registration, the old job is stopped, the registration is replaced with
the calling interpreter's, and the job is started only when saved intent is
running. Every publication and removal is followed by a directory fsync, so a
durable journal phase never claims a file that did not survive. A migration
keeps every data-affecting pin (backend, database, session and env-file
paths) byte-for-byte; a registration whose pins differ from the home's
current configuration is refused with service_config_mismatch before
anything is stopped. The output directory is not a pin: the CLI resolves
its default against the install working directory, the watch service never
writes exports, and a migration normalizes it to the home. Any failure or
crash rolls back to the retained bytes and the previous intent; a previous
registration that will not start again after the rollback (its interpreter
may be gone) still completes the rollback and is reported as service_error
with nothing pending, so a later service.migrate can retry.
service.repair finishes an interrupted rollback and never completes a
migration forward, and it touches only bytes recorded by the migration
(previous, candidate, or the mixed pair a death between the two file
replacements leaves) — anything else is service_ownership_unknown and stays
untouched. A retained document that is not a readable registration blocks
every transition as recovery_required; when no journal references it,
service.repair discards it (it can never serve a rollback), while a document
the service layer refuses to read at all stays pending. A registration whose
manifest, plist or loaded job (program, arguments and, when launchd reports
it, the plist path) cannot be proven owned is service_ownership_unknown for
every mutation, as is a registration that changed between an unlocked read and
the management lock; reads still describe it. A manifest without its plist is
owned but incomplete: migration completes it, uninstall removes it, and a
loaded job without its plist is unknown.
Every mutation re-validates the binding after taking the profile lock: a
request resolved against one home while another process selected a different
one fails with profile_busy (retryable) instead of acting on the wrong home.
Reads act on the binding as resolved.
setup.inspect/settings.inspect keep their C1 meaning: while the
registration names another interpreter (a CLI-registered service, or a
migration that was rolled back) they report service_error, because the
owned lifecycle cannot manage that registration; service.inspect shows
interpreter: other so a client can offer migration instead of repair.
service.repair after such a rollback returns the same service_error DTO
once the journal is settled. home.inspect inspects a WAL-backed database
through a private disposable copy inside its 10-second budget; a very large
uncheckpointed store can therefore report operation_timeout, which is not
an adoption verdict. home.inspect and service.inspect map an unexpected
storage failure to storage_error, never to internal_error.
New static codes: home_invalid, home_backend_unsupported,
service_ownership_unknown and service_config_mismatch (none retryable).
Existing codes keep their meaning; adopted homes with an incompatible database
report schema_mismatch.