@saptools/cf-debugger
Open a verified Node.js inspector tunnel to an SAP BTP Cloud Foundry app from one
command. Each concurrent session has its own local port, isolated CF_HOME, and
lock-guarded state entry.
Install
npm install -g @saptools/cf-debugger
Node.js 20+ and the official cf CLI are required. CF CLI v8 is recommended.
Non-web process targeting uses cf ssh --process and requires CF CLI v7+.
On macOS, lsof is a soft dependency used to prove which PID owns a local
listening port. Install it if cf-debugger reports TUNNEL_OWNER_UNVERIFIED.
Without it, an explicit stop refuses to signal a state-recorded PID whose port
ownership cannot be proved; teardown still completes when the spawned child is
independently confirmed dead.
Quick start
export SAP_EMAIL="you@company.com"
export SAP_PASSWORD="your-sap-password"
cf-debugger start \
--region eu10 \
--org my-org \
--space dev \
--app my-app \
--verbose
Successful output includes the complete attachment target:
Debugger ready for my-app (eu10/my-org/dev).
Process: web
Instance: 0
Local port: 20142
Remote port: 9229
Session id: 550e8400-e29b-41d4-a716-446655440000
Tunnel PID: 83421
Node PID: 4312
Press Ctrl+C to stop.
Attach the IDE to 127.0.0.1:20142. Node derives the advertised
webSocketDebuggerUrl from the incoming request's Host header, so a URL
returned through the forward already names the local forwarded port and is
directly usable. cf-debugger validates that the URL is well formed but does
not print or follow it.
CLI
start
cf-debugger start --region eu10 --org my-org --space dev --app my-app
cf-debugger start eu10/my-org/dev/my-app --remote-port 9230
cf-debugger start my-app --process worker --instance 2 --node-pid 4312
cf-debugger start my-app --startup-timeout 300 --timeout 180 --verbose
When region, org, or space is omitted, the current cf target supplies it.
| Flag | Meaning |
|---|---|
--region <key> |
SAP region key; defaults to the current CF target |
--api-endpoint <url> |
Absolute HTTPS endpoint override, including private/nonstandard hosts |
--org <name>, --space <name>, --app <name> |
CF target and app |
--process <name> |
CF process, default web |
-i, --instance <index> |
Zero-based process instance, default 0 |
--node-pid <pid> |
Exact remote Node PID |
--port <number> |
Preferred local port; otherwise choose from 20000–20999 |
--remote-port <number> |
Port where cf-debugger looks for an existing remote inspector, default 9229 |
--timeout <seconds> |
Local-tunnel and inspector-readiness budget, default 180 |
--startup-timeout <seconds> |
Overall startup deadline, default 300, maximum 1800 |
--allow-ssh-enable-restart |
Permit app-level SSH enablement and one app restart |
--no-ssh-enable-restart |
Explicitly forbid that mutation, overriding flag/environment |
--verbose |
Print status, retry, and redacted tunnel transport diagnostics |
App restart is opt-in in 0.2.0. By default, a disabled-SSH app fails with
SSH_NOT_ENABLEDand no deployment mutation. Use--allow-ssh-enable-restart, or setCF_DEBUGGER_ALLOW_RESTART=1, only when restarting the named app is acceptable.CF_DEBUGGER_ALLOW_RESTART=0keeps the guard enabled for a production shell and also vetoes a programmaticallowSshEnableRestart: true. The environment value1can opt in the CLI, but never grants permission to a programmatic caller by itself.
--api-endpoint accepts arbitrary hostnames but requires an absolute https:
URL with no userinfo, query, fragment, or non-root path. Invalid values are
rejected before cf api or cf auth. Version 0.2.2 intentionally rejects
plaintext http:// landscapes because credentials would be sent to that
endpoint.
Even with permission, a restart occurs only when all of these are true:
- the one-shot SSH error specifically says SSH support is disabled;
cf ssh-enabled <app>proves app-level SSH is disabled;- this invocation runs
cf enable-ssh <app>; - a second probe proves app-level SSH is now enabled.
An unknown probe result, an already-enabled app, a permission error, or a
space-level SSH policy never causes a restart. Before an allowed restart,
stderr names the app and org/space. With an explicit --node-pid, automatic
restart is always rejected because the container replacement invalidates that
PID.
Read-only CF commands have a 60-second per-attempt cap and retry transient
transport errors with bounded exponential delays while the single overall
startup budget still has time. Deployment-mutating commands have a separate
180-second cap and are never retried: a timed-out enable/restart reports
CF_MUTATION_TIMEOUT because the server-side mutation may still be completing.
cf auth has no outer retry loop, and rejected credentials fail after one
attempt; repeated manual failures may count toward a tenant's identity-provider
lockout policy. The one-shot SSH signal and readiness wait are also clamped to
the same overall deadline. No retry configuration multiplies startup work past
--startup-timeout. After a timeout, fail-closed tunnel and state cleanup still
runs; that safety teardown can briefly extend the observed command wall time
rather than abandoning an unverified child.
stop
cf-debugger stop --session-id 550e8400-e29b-41d4-a716-446655440000
cf-debugger stop eu10/my-org/dev/my-app
cf-debugger stop my-app --api-endpoint https://api.cf.eu10.hana.ondemand.com
cf-debugger stop my-app --node-pid 4312
cf-debugger stop --all
cf-debugger stop --session-id 550e8400-e29b-41d4-a716-446655440000 --force
stop --all attempts every local session, reports stopped, forced, stale,
pending, and failed outcomes separately, and exits 70 if any tunnel cleanup
failed (1 for other failures). It cannot
be combined with a positional selector, --session-id, or target selector
options; ambiguous scope is rejected instead of widening to every session.
stop --force means “forget safely,” not “kill harder.” It never signals a PID
whose tunnel ownership is unproven. It forgets the state record first, then
best-effort removes only the exact derived v2 CF_HOME. A deletion failure is
non-fatal and warns that the retained path may contain a live refresh token;
doctor then reports it as an orphan eligible for doctor --cleanup. The
warning also names the abandoned PID and port for manual investigation. If a
damaged record names a non-owned home path, that path is left untouched while
the record is still forgotten.
list and status
cf-debugger list
cf-debugger status --session-id 550e8400-e29b-41d4-a716-446655440000
cf-debugger status eu10/my-org/dev/my-app --api-endpoint https://api.example
list prunes only records proven stale. PID liveness is paired with an optional
OS process-start token to detect PID reuse; older records without the additive
token retain the compatible PID-only check. A ready record is healthy only when
the complete listener-owner set contains its recorded tunnel PID. Ownership
that cannot be inspected remains explicit rather than being guessed.
status prints null only when no exact session matches. Ambiguity is a coded
SESSION_AMBIGUOUS failure; refine with session ID, API endpoint, or Node PID.
doctor
cf-debugger doctor
cf-debugger doctor --cleanup
The default is read-only JSON reporting. It includes:
- every local state record with its health verdict and reason;
- v2 session homes with no state record;
- listeners in
20000–20999that no state record claims; - leftover state temp, lock, recovery, stop-intent, and corrupt-backup files;
- legacy v1 state/homes, home count, parseable claimed sessions, conservative PID liveness, and their credential-retention risk.
--cleanup removes only canonical orphan v2 homes and sufficiently old,
package-owned temp/lock/recovery/stop-intent artifacts. It revalidates orphan
homes against lock-guarded state immediately before deletion. It never signals
an unclaimed listener, removes corrupt evidence, or removes legacy v1 artifacts.
Corrupt backups include a manual-removal command in the report while remaining
ineligible for automatic cleanup. A symlinked v2 homes root is reported and
never traversed.
Region resolution
Curated region keys are available from listKnownRegionKeys(). Keys matching
aa00 or aa00-000 also work before the curated table is updated:
cf-debugger synthesizes https://api.cf.<key>.hana.ondemand.com, or the
.platform.sapcloud.cn domain for cn*, and warns with the synthesized URL.
This supports new regions but makes a syntactically valid typo such as eu99
reach DNS; verify the warning or use --api-endpoint. Malformed keys fail early
with UNKNOWN_REGION.
How it works
- Resolve and validate the endpoint, deadline, target, and credentials.
- Prune only provably stale state and register a unique session/port.
- Run
cf api, environment-onlycf auth,cf target, and an app existence check. - Probe the selected instance through one-shot
cf ssh. - If an inspector already owns the remote port and is Node, reuse that PID.
- Otherwise:
- choose the sole Node PID when exactly one exists;
- with several Node PIDs, use the app
$PORTlistener only as a tiebreaker; - otherwise fail
NODE_PROCESS_AMBIGUOUS.
- Send
SIGUSR1when needed and prove the selected PID owns the configured remote inspector port. - Spawn detached
cf ssh -N -L <local>:localhost:<remote>. - Prove the spawned PID is among all owners of the local listener.
- Request
GET /json/listthrough the tunnel and require HTTP 200 plus a non-empty target array whose first entry has a well-formedwebSocketDebuggerUrl. - Persist
readystate and return aDebuggerHandle.
Node's Linux cluster default can leave the app $PORT socket owned by the
cluster primary rather than a request-handling worker. When several Node
processes exist, inspect the reported candidates and pass --node-pid
explicitly if a worker is the intended target.
The remote probe reads /proc/<pid>/exe, not argv. Its marker lines are filtered
by a strict saptools-inspector-* prefix, so CF SSH banners cannot impersonate
results. GNU find -lname is capability-probed and is normally fast; the
portable fallback walks descriptors with readlink and can take roughly
10–12 seconds in a descriptor-heavy non-GNU container.
--remote-port does not choose the port opened by SIGUSR1; Node always uses
its default inspector port for that signal. A non-default value is therefore
for an app already started with matching --inspect=<port>.
Status sequence
The public onStatus sequence is:
starting → logging-in → targeting → signaling
→ [ssh-enabling → ssh-restarting → signaling]
→ tunneling → ready → stopping → stopped
Verbose retries repeat the current phase with a message and remaining budget.
After starting is emitted, startup failure emits terminal error and never
follows it with stopped. Option/credential/region validation can reject before
the status stream begins, in which case no callback is emitted.
Unexpected tunnel failure after readiness likewise emits terminal error;
cleanup still runs without claiming a normal stop sequence.
State and security
~/.saptools/cf-debugger-state-v2.json
~/.saptools/cf-debugger-state-v2.lock
~/.saptools/cf-debugger-homes-v2/<sessionId>/
State is written as a 0600 temp file plus atomic rename while a token-owned
open(..., "wx") lock is held. The parent and session-home directories are
0700. Keep ~/.saptools on a local filesystem: exclusive-create locking is
not reliably atomic on every NFS/SMB implementation. A generous age fallback
recovers foreign-host lock files, but it cannot make a network filesystem's
locking semantics safe.
Each isolated CF_HOME contains CF CLI credentials, including a live refresh
token. A clean stop removes it. If TUNNEL_TERMINATION_FAILED retains state and
the home, recover the process first, then run stop --force. If a record has
already disappeared but its canonical home remains, use doctor --cleanup.
Do not leave recovered homes indefinitely.
A damaged entry no longer erases healthy sessions. Invalid entries are dropped
individually, and the original state file is moved to a private
.corrupt-<timestamp>-<uuid> backup before any repair. Completely invalid JSON,
version, or root shape is likewise preserved before a fresh empty v2 file is
written.
During mixed-version use, an older cf-debugger sharing this v2 file may strip
optional fields it does not understand, including process-identity and startup
budget data. Newer code treats an absent/legacy identity as “cannot tell” and
retains the record with PID-only compatibility rather than pruning it; doctor
surfaces that degraded verdict. Updating every installed consumer is the only
way to restore the stronger identity check persistently.
Legacy ~/.saptools/cf-debugger-homes/ directories may still contain live
refresh and access tokens. doctor reports but never deletes them. After
confirming no v1 tunnel is running, remove them explicitly:
rm -rf "$HOME/.saptools/cf-debugger-homes" \
"$HOME/.saptools/cf-debugger-state.json" \
"$HOME/.saptools/cf-debugger-state.lock"
The v2 CLI never adopts or mutates v1 sessions. Prefer CLI/API operations over editing the v2 JSON by hand.
Credentials are passed to cf auth as CF_USERNAME and CF_PASSWORD in the
child environment, never argv. CF command and tunnel diagnostics are bounded
and redact sensitive values before they reach errors or verbose output.
CF_COLOR=false is forced so ANSI escapes cannot corrupt output parsing.
Environment variables
| Variable | Meaning |
|---|---|
SAP_EMAIL, SAP_PASSWORD |
CF authentication credentials |
CF_DEBUGGER_ALLOW_RESTART=0|1 |
CLI default; 0 is also a hard veto for the programmatic API |
CF_DEBUGGER_CF_BIN |
Replace the cf executable; useful for wrappers and deterministic test stubs |
A caller-provided child environment cannot override the per-session CF_HOME.
Errors and exit status
Documented domain failures use Error [CODE]: .... An unexpected operating
system or filesystem failure can still surface as a plain Error without a
domain code; it is never converted into success.
| Code | Meaning |
|---|---|
UNKNOWN_REGION, UNSAFE_INPUT, MISSING_CREDENTIALS |
Invalid endpoint/key, selector, numeric input, or credentials |
CF_LOGIN_FAILED, CF_AUTH_FAILED, CF_TARGET_FAILED, CF_CLI_FAILED, CF_CLI_TIMEOUT |
CF command failure; rejected auth is not retried |
CF_MUTATION_TIMEOUT |
A deployment mutation timed out, was not retried, and may still be completing remotely |
STARTUP_TIMEOUT, ABORTED |
Overall deadline expired, or caller/stop intent cancelled startup |
APP_NOT_FOUND |
The targeted app does not exist in the selected org/space |
SSH_NOT_ENABLED, SSH_PERMISSION_DENIED, SSH_STATE_UNKNOWN |
Restart refused/not useful, permission denied, or SSH state ambiguous |
NODE_PID_RESTART_UNSAFE |
Explicit PID cannot survive an app restart |
NODE_PROCESS_NOT_FOUND, NODE_PROCESS_AMBIGUOUS, NODE_PID_INVALID |
Remote Node selection failed closed |
USR1_SIGNAL_FAILED, INSPECTOR_NOT_READY, INSPECTOR_OWNER_MISMATCH, INSPECTOR_OUTPUT_TOO_LARGE |
Remote signal/ownership protocol failed |
TUNNEL_NOT_READY |
The spawned local forward did not bind |
PORT_UNAVAILABLE, TUNNEL_PROCESS_MISSING |
Local port allocation failed, or the spawned tunnel exposed no PID |
TUNNEL_OWNER_UNVERIFIED, TUNNEL_OWNER_MISMATCH |
Local listener ownership could not be proved |
INSPECTOR_UNREACHABLE |
Local forward bound, but /json/list did not prove an attachable remote inspector |
TUNNEL_EXITED, TUNNEL_TERMINATION_FAILED, TUNNEL_OWNERSHIP_UNVERIFIED |
Tunnel died, cleanup could not terminate it, or stop could not safely signal it |
SESSION_ALREADY_RUNNING, SESSION_AMBIGUOUS, SESSION_NOT_FOUND |
Session selection conflict |
SESSION_STATE_LOST, SESSION_STATE_CONFLICT, STATE_LOCK_TIMEOUT |
Atomic state/lifecycle invariant failed |
CF_HOME_CLEANUP_FAILED |
Session state was removed but lifecycle cleanup could not remove its exact credential-bearing CF home |
PACKAGE_METADATA_INVALID |
Runtime package metadata has no usable version |
STOP_FAILED |
One entry in a batch stop failed with a non-coded internal error |
| Exit | Meaning |
|---|---|
0 |
Clean completion |
1 |
Coded operational/startup error, including STARTUP_TIMEOUT |
70 |
Tunnel or lifecycle cleanup failed; ownership state or a credential-bearing home may remain |
| SSH child's nonzero code | An unexpected numeric SSH exit is preserved, for example 255 |
128 + signal |
Tunnel child died from a signal, for example 137 for SIGKILL |
130, 143 |
User interrupted with SIGINT or SIGTERM |
Programmatic API
import {
startDebugger,
stopDebugger,
stopAllDebuggers,
listSessions,
runDoctor,
} from "@saptools/cf-debugger";
const handle = await startDebugger({
region: "eu10",
org: "my-org",
space: "dev",
app: "my-app",
startupTimeoutMs: 300_000,
allowSshEnableRestart: false,
});
// Attach to handle.session.localPort.
await handle.dispose();
const summary = await stopAllDebuggers();
// Outcomes and counts distinguish stopped/forced/stale/pending/failed.
Persisted schema additions in 0.2.0 are optional. Absence means compatible
fallback behavior because an older mixed-version writer may strip fields it
does not know. State remains version "2".
FAQ
Does this modify the remote app?
Not by default. Inspector activation sends SIGUSR1 to the verified Node
process, but app-level SSH enablement and cf restart require explicit
--allow-ssh-enable-restart permission. Even with permission, restart occurs
only after this run changes app SSH from proven disabled to proven enabled; an
already-enabled, unknown, permission-denied, or space-policy result never
restarts the app.
Is session state always removed when the CLI exits?
Only after cleanup is verified. TUNNEL_TERMINATION_FAILED deliberately retains
state and the isolated credential-bearing CF_HOME; recover it with doctor
and stop --force instead of assuming the next start can proceed.
Development
pnpm --filter @saptools/cf-debugger lint
pnpm --filter @saptools/cf-debugger typecheck
pnpm --filter @saptools/cf-debugger test:unit
pnpm --filter @saptools/cf-debugger build
The fake-backed tests set CF_DEBUGGER_CF_BIN. Live E2E tests require a safe
non-production CF target and credentials; do not run them against production.