8 min read

What do force quit tests prove about an agent gateway?

Force quit tests expose what an agent gateway can prove after a crash, from credential injection and network I/O to audit commits.

What do force quit tests prove about an agent gateway?

A gateway that holds credentials for an AI agent has to stay safe when its process disappears at the worst possible instant. A happy-path test can show that an approval appears, a request succeeds, and an audit record exists afterward. It cannot show whether a credential escaped during an interrupted request or whether the audit trail lies about work that never completed.

Force quit tests expose the gaps between statements such as "request started," "credential was attached," "remote system received it," and "the record is durable." Those statements are separate facts. Treating them as one event is how teams ship an action gateway that looks controlled until a crash turns an ordinary retry into a duplicate deployment or an untraceable outbound call.

For a macOS gateway, distinguish a normal quit from process death. Apple documents normal app termination as a lifecycle path where the app can save state and run termination handling. A forced termination may not grant that work at all. Apple also identifies SIGKILL as a termination type that can result when a user force quits an app. Your test plan must assume cleanup handlers, deferred writes, and best-effort telemetry do not run.

The useful question is not "does the app restart?" The useful question is: at every interruption point, can you state exactly which side effects are possible, which are impossible, and what evidence survives?

A force quit must cut through cleanup

A force quit test is valid only when it removes the gateway's opportunity to tidy up. If the test calls a polite shutdown function, waits for queues to drain, flushes logs, and then exits, you have tested orderly termination. That matters, but it does not map the failure case that keeps security engineers awake.

Use two termination modes and label them accurately:

  • A normal terminate request tests cancellation handling, orderly file closure, and how the UI reports an interrupted session.
  • A hard kill tests the state of memory, file buffers, open sockets, and in-flight work when no application cleanup runs.

On macOS, a test harness can use kill -TERM for the first case and kill -KILL for the second. SIGTERM gives a process the chance to handle termination. SIGKILL does not. Do not call both cases "force quit" in the result table, because they answer different questions.

# Find the gateway process you started for the test.
pgrep -fl Sallyport

# Graceful termination case.
kill -TERM 48192

# Abrupt termination case.
kill -KILL 48192

# Confirm the process is gone.
ps -p 48192
# PID TTY           TIME CMD
# 48192 ttys003    0:00.42 gateway-test
# After SIGKILL, ps should print no process row.

The process ID is not the test identifier. Give every test run a run ID, an action ID, and a remote request ID. Put those IDs in the fixture's records and the gateway's records. Without shared identifiers, you will end up comparing timestamps and guessing whether a remote POST belonged to the run you killed.

Do not test against a production endpoint, even if you believe the call is harmless. The purpose of this work is to create ambiguous outcomes deliberately. Build a receiver that can pause before accepting a body, after reading headers, after recording the body, or before releasing its response. A real third-party API will not reliably give you those boundaries, and it may impose retries or caching you did not ask for.

The request needs explicit interruption points

You cannot kill a process "during credential injection" unless the implementation defines what that means. Add observable hold points around the action pipeline. These are test controls, not a policy language and not a production authorization mechanism.

For an HTTP action, use a sequence like this:

  1. The gateway accepts an authorized action description and assigns an action ID.
  2. It writes an intent record, if the design requires one before network work.
  3. It resolves the credential inside the protected process.
  4. It constructs the outbound request and injects the credential.
  5. It opens the connection and writes the request.
  6. It receives enough of the response to classify the remote outcome.
  7. It writes a completion or unknown-outcome record, then releases a result to the agent.

Install a pause at the boundary after each numbered operation. The pause must be observable from a separate process. A test-only local socket, a named pipe, or a file descriptor controlled by the harness works. A sleep call does not work well because timing drift turns a boundary test into a race.

A compact fixture protocol can look like this:

{
  "action_id": "fq-2026-07-22-017",
  "hold_after": "request_headers_built",
  "release": false
}

The gateway pauses after creating headers but before any connection attempt. The harness confirms that state, kills the process, then asks the receiver whether it saw a connection. The expected outcome is zero inbound requests and zero credential-bearing headers. If the receiver saw a request at this point, your checkpoint sits too late or a background task crossed the boundary without being accounted for.

Make the checkpoints narrow. "Before network I/O" is too broad if DNS resolution, connection creation, TLS negotiation, and body transmission run in different tasks. You do not need a checkpoint inside every library call, but you do need enough structure to state whether the remote server could have received credentials or a state-changing body.

For SSH, the same idea applies with different evidence. Hold before sp-ssh receives a credential-backed connection request, after the helper starts but before authentication, after authentication but before the command begins, and after the remote command returns but before local completion is committed. Record remote command start and exit in a disposable test host. Do not infer command execution from an open TCP connection.

Killing before injection must leave no remote trace

The earliest failure case has the cleanest expected result: if the gateway dies before it has attached a credential or started transport, no external service should see anything from that action.

That sounds obvious, but implementations often blur preparation and dispatch. A client library might begin a connection while another task fetches or formats a header. A retry wrapper might allocate an outbound request before the code that writes the intended journal record. A metrics callback might write an action description containing a URL query parameter that your normal redaction path never sees.

Test four pre-dispatch cuts:

  • After authorization but before any credential lookup.
  • After credential lookup but before request construction.
  • After request construction but before a socket connection begins.
  • After connection setup begins but before any credential-bearing bytes leave the process.

Each cut has a different assertion. Before credential lookup, inspect memory-safe diagnostics and logs for the absence of values or placeholders that could later be substituted. After lookup, inspect the same outputs and verify the secret never escaped the process boundary. Before connection, the receiver should have no connection record. During connection setup, the receiver may see a failed handshake or connection attempt, but it must not receive an authorization header, basic-auth field, custom credential header, or SSH authentication attempt.

This distinction matters because a connection attempt is not an authenticated action. Do not report "no action occurred" if the remote edge recorded an attempted connection. Report the narrow truth: no credential was sent and no application request was received. Security records become less useful when they smooth away evidence that operators will later need during incident review.

Sallyport's design makes this boundary worth testing directly: the agent should never hold a plaintext credential, while the app performs the credential-backed HTTP or SSH action and returns a result. The test is not complete because the agent lacks the secret. It is complete when a killed gateway also cannot turn a half-prepared action into a secret-bearing outbound request.

Injection is a local event, not proof of dispatch

Credential injection is where teams make the most damaging bookkeeping mistake. They log "credential used" when the code builds a header or hands an identity to an SSH library. That event tells you the gateway prepared to authenticate. It does not prove that any peer received it.

Keep at least four local states separate:

StateWhat you can honestly claimWhat you cannot claim
Credential resolvedThe protected process read a credential for this actionA peer received it
Request assembledThe process created a credential-bearing request in memoryA connection opened
Transport write startedThe process attempted to send bytesThe remote application processed them
Remote outcome observedThe process received evidence from the remote sideThe completion record is durable

Run a kill after the credential is resolved and another after the request object exists but before the transport write starts. In both cases, the agent must receive no secret, and the remote fixture must record no credential-bearing request. The action journal should show either an interrupted preparation state or no persisted record, depending on when your durability boundary sits. Do not manufacture a completed action just to make the journal look tidy.

Then kill at the first point where the transport library can write. This is the awkward test. The local process may have called a write function, but the kernel, proxy, TLS layer, or remote service may not have received the complete request. Your expected result should be outcome=unknown unless the receiver has positive evidence that it did or did not receive the request.

Avoid logging full request headers to make this test easier. That is a shortcut that creates a second secret path. Instead, make the fixture calculate a nonreversible test marker from the received credential value and store only that marker. The gateway can store a credential reference or an internal credential identifier. During the test, compare identifiers and markers without printing the credential itself.

A useful fixture record has this shape:

{
  "action_id": "fq-2026-07-22-017",
  "connection_seen": true,
  "headers_complete": true,
  "credential_marker": "sha256:7b8c...",
  "body_complete": false,
  "response_sent": false
}

Use a test-only credential with no value outside the fixture. Even then, keep it out of screenshots, shell history, and ordinary logs. A disposable secret is still a secret-shaped object, and test habits have a way of reaching production code.

Network I/O creates an honest unknown state

Keep HTTP authentication contained
Route bearer, basic, and custom-header authentication through Sallyport instead of exposing keys to agents.

Once a state-changing request can leave the machine, local certainty ends before the remote system necessarily answers. This is the case that must shape retry behavior, UI wording, and audit semantics.

Consider a POST that asks a deployment service to start a release. The gateway writes the full request. The service persists the deployment job. Before the response reaches the gateway, you kill the gateway process. On restart, there are three possible realities:

  1. The service never received the request.
  2. The service received the request but rejected it.
  3. The service accepted the request and created a deployment.

The local gateway may not know which reality occurred. A record marked failed is false in case three. A record marked succeeded is false in cases one and two. Mark it unknown and preserve the action ID, remote request ID, destination, method, and the point at which local observation stopped.

This is also where blind retries cause damage. Teams love automatic retry because transient network failure is common and successful demonstrations look smooth. For a GET, retry is usually acceptable if it does not cause server-side accounting or rate-limit trouble. For a POST, PATCH, remote command, or write-capable API call, retry requires an idempotency mechanism or later reconciliation.

Use the remote system's idempotency feature when it has one. Supply an action-specific idempotency token, not a token reused for the entire agent session. If the remote API does not support it, create a remote lookup path that can answer whether the action ID was accepted before you retry. If neither exists, ask a human to decide. That is less elegant than automatic recovery and far safer than double execution.

Test this exact sequence with your receiver:

Gateway writes intent record
Gateway sends POST with action ID fq-2026-07-22-017
Receiver stores action ID and body
Receiver delays its HTTP response
Harness kills gateway with SIGKILL
Gateway restarts
Agent requests retry
Gateway checks receiver for action ID before another POST

The result should show one stored remote action and one local record with an interrupted or unknown outcome that later reconciles to the remote state. If the second POST appears before the reconciliation check, the test has found a retry bug. If the journal overwrites the uncertainty and leaves only a clean success record, it has found an audit bug.

Do not depend on TCP close events as proof that a remote application did not act. A socket can close after the peer has already passed the request to its application code. The receiver's durable action record is the evidence that matters.

Audit commit must have a defined durability promise

An audit log does not become trustworthy because it contains a hash chain. A hash chain can reveal alteration or removal within the surviving sequence. It cannot recover an event that never reached durable storage before the process died.

Write down the promise in one sentence. For example: "Before a gateway begins a state-changing external action, it durably records the action intent; after it observes an outcome, it durably records that outcome before returning it to the agent." Then test the two verbs, "begins" and "durably," instead of treating a timestamped in-memory object as a record.

POSIX defines fsync() as a request to transfer a file's data to associated storage and says it does not return until that action completes or reports an error. The standard also warns that storage guarantees depend on the implementation and configuration. That warning is not an excuse to skip the call. It means you must be precise about what your software can promise and test the recovery behavior it controls.

For a hash-chained encrypted log, test at least these cuts:

  • Kill after writing a proposed entry but before its durability barrier.
  • Kill after the durability barrier but before the in-memory index updates.
  • Kill after a completion entry is durable but before the agent receives its response.
  • Kill during compaction, rotation, or any journal projection process.

After each restart, run the offline verification command and inspect both the raw audit sequence and the user-facing projections. Sallyport exposes sp audit verify for verification of its encrypted hash-chained audit log without requiring the vault key. That makes it useful in this test set, but verification should be one assertion among several, not the whole test.

Your expected results need nuance. A record absent after a kill before its durability boundary can be acceptable if no external action began. A record absent after the gateway started a state-changing request is not acceptable if the stated design promised write-ahead intent. A record present with an unknown result is often correct. A record that claims completion before the remote response was observed is wrong, even if the test usually happens to pass.

Keep agent-run history separate from action-call history when you inspect recovery. A session can end abruptly while several actions have different outcomes. One run-level entry saying "terminated" does not replace per-call records for a request that reached the remote system and another that never left memory.

Approval state must not outlive its subject

Keep credentials out of crashes
Keep API and SSH credentials in Sallyport's encrypted vault, never in the agent process.

A restart tests authorization state as much as durability. If a gateway authorizes a particular agent process for a session, killing the gateway must not turn that authorization into a reusable permit for a new process that happens to make a similar request.

The simplest safe rule is that a session approval belongs to an observed process identity and dies with that run. After restart, a new agent process needs a new session authorization. A current process that remained alive while the gateway restarted also needs a fresh decision unless the gateway can re-establish the exact identity binding and the product deliberately documents that behavior. Do not restore approval from a loose cache record such as command name, working directory, or a friendly process label.

Test the restart boundary with two agents that look similar from the command line but differ in signing authority or executable origin. Approve the first. Stop the gateway during an action. Restart it. Then have the second agent issue the same action. The gateway must show a new approval decision rather than inheriting the first agent's session.

Per-call approval has a different test. Kill the gateway while the approval card is visible, then restart and repeat the action. The stale UI event must not authorize the new call. Kill after the user approves but before credential resolution, then confirm that the new process cannot reuse the old approval. These tests catch a common error: persisting the boolean "approved" without binding it to an action ID, a specific session, and an expiry condition.

Sallyport's fixed controls give this test a clear shape. Its vault gate denies actions while locked, its session authorization is tied to a new agent process, and selected credentials can require approval on every use. Force quit testing should prove those boundaries remain true when the UI, vault state, and action pipeline restart at different moments.

Build a result matrix before you run the suite

Approval follows the process
Require a fresh approval for each new agent process before its session can act.

A test that merely says "kill at stage X" leaves too much room for interpretation. Create a matrix with rows for interruption points and columns for observable outcomes. Review the expected result before you write the test code. If the team cannot agree what should happen, the implementation does not yet have a defined failure contract.

Use these columns:

Cut pointMay remote see a connection?May remote see credentials?Permitted local action statusAgent result after recoveryRequired evidence
Before credential lookupNoNoNot started or interruptedNew approval or retry pathGateway trace only
After credential resolutionNoNoInterrupted preparationNew approval or retry pathGateway trace only
During request writeYesPossiblyUnknownReconcile before retryReceiver record and local record
After remote acceptanceYesYesUnknown until observed or reconciledReconcile before retryRemote durable record
After durable completionYesYesCompletedReturn cached or reconciled resultVerified audit record

The words "possibly" and "unknown" are not weaknesses. They are an honest description of distributed work. The dangerous word is "failed" when the gateway has no evidence that the remote service failed to act.

Run each row repeatedly, but do not turn repetition into a substitute for control. One hundred random kills can miss the line between a journal append and its durability barrier. A single controlled kill at a hold point can establish what that line does. Repeat afterward to catch races, scheduling mistakes, and accidental movement of the checkpoint.

Collect artifacts from both sides on every run: the harness event timeline, the remote fixture's durable action list, process exit information, gateway recovery diagnostics, audit verification output, and agent-visible output. Put the run ID in the filename. If a test fails, preserve the evidence before rerunning it. The second run often destroys the only useful clue.

Treat discrepancies as design work, not test noise

When a force quit test produces a disagreement between the gateway journal and the remote fixture, resist the urge to patch the assertion until it passes. The disagreement is usually the test finding.

If the fixture reports an accepted request and the gateway records no intent, move the durable intent boundary earlier or stop the action before that boundary. If the gateway reports completion and the fixture has no action, identify whether the gateway confused a local write with a remote result. If a restarted agent can act without a fresh approval, fix identity binding rather than adding a longer timeout.

The best outcome is not a dashboard full of green tests. It is a failure contract that an operator can use under pressure: this action definitely did not leave the machine; this action may have reached the remote system and needs reconciliation; this action completed and its record survived verification. Those are the only categories that make a post-crash decision defensible.

Run this suite whenever you change credential plumbing, transport libraries, journaling, retry behavior, process supervision, or approval handling. A gateway earns trust by behaving predictably when it gets no chance to explain itself.

FAQ

What is the difference between quitting and force quitting an agent gateway?

A graceful quit gives the process time to run cleanup code, flush buffers, and settle open work. A force quit is useful because it removes that courtesy. Test both, but treat the forced termination result as the one that defines your failure boundary.

Is canceling a request enough to test gateway crash safety?

No. A canceled request only proves that your client can handle a normal cancellation path. A killed gateway tests whether credentials, audit records, in-flight sockets, and approval state remain safe when the process gets no chance to clean up.

What should happen if the gateway dies before credential injection?

Kill it after the request has been accepted but before the gateway selects or reads a credential. The expected result is simple: no outbound connection, no credential-derived header, and a journal entry that describes an interrupted attempt if the design records attempts at that point.

How should an agent gateway handle a crash during network I/O?

The gateway should not claim a completed action unless it has durable evidence of the result it claims. If the remote system may have received the request but the gateway lost the response, record the outcome as unknown and make the operator reconcile it with the remote system.

Should an agent receive raw HTTP errors after a failed request?

No. A response body can contain secrets, personal data, tokens, or operational details that an agent did not need to receive. Return the smallest result that lets the agent proceed, and keep transport diagnostics out of ordinary agent-visible output.

What is the difference between an intent record and a completion record?

A write-ahead record is durable intent recorded before a side effect begins. A completion record is proof of the observed result after the side effect. If you write them in the opposite order, a crash can produce an audit trail that tells a story the network never did.

Can an agent safely retry after the gateway is force quit?

No. Retrying blindly after an interrupted POST can create two tickets, two charges, two deployments, or two destructive changes. Retry only when the remote API supports idempotency, when the operation is naturally idempotent, or after an operator resolves the unknown outcome.

How do I build a safe environment for force quit testing?

Use a server you control or an HTTP test fixture that records connection time, headers, body receipt, and response release. Add a hold point so the request can pause at each phase, then kill the gateway from a separate shell while the hold point is active.

What should happen to approvals when the gateway restarts?

The approval should expire with the agent process or session, not survive merely because the gateway restarted. A restart must not turn an interrupted run into a silently authorized new run. Require a fresh session decision when the identity boundary has changed.

Does a hash-chained audit log prove that no action was lost?

It proves that the gateway can detect tampering with the audit sequence that remains on disk. It does not prove that every attempted action reached durable storage before an abrupt kill. You still need controlled interruption tests and remote-side evidence for the ambiguous network window.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov