Skip to content

Add ssh agent-shim subcommand - #6734

Open
rclarey wants to merge 4 commits into
mainfrom
ssh-agent-shim
Open

rclarey wants to merge 4 commits into
mainfrom
ssh-agent-shim

Conversation

@rclarey

@rclarey rclarey commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Changes

Add a hidden ssh agent-shim subcommand which wraps Unity Gateway CLI to install, configure, and launch coding agents in SSH sessions

Why

Today it’s possible to get an agent harness working in an SSH session, but the process is entirely manual and undocumented. After connecting, a user must:

  • know of the Unity AI Gateway endpoints that Databricks exposes
  • manually install an agent harness (including runtime dependencies like NPM)
  • correctly configure their agent harness to use Unity AI Gateway
    • e.g. for Claude Code this means setting ~8 environment variables plus the x-databricks-use-coding-agent-mode header
  • do this for every SSH session on serverless since instances are ephemeral

The limits of this are that nobody knows this recipe exists, and that it is easy to misconfigure. For example, if the small/fast model tier for Claude isn't mapped to a Databricks endpoint, background calls silently fall back to api.anthropic.com and fail.

The ssh agent-shim subcommand this PR introduces solves this problem by handling installation of all needed dependencies, then deferring to Unity Gateway CLI to install and configure agents. A later PR will add shims for claude/codex/etc added to PATH which call this subcommand. This will allow users to simply run the command they are used to and get an agent working out-of-the-box.

Tests

Added unit tests

Manually testing

  • clone this branch
  • ./task build snapshot-release
  • databricks ssh connect --releases-dir ./dist
  • ./.databricks/ssh-tunnel/<release number>/databricks_cli_<release number>_linux_<CPU arch>/databricks ssh agent-shim [claude|codex] in your workspace user folder (should be default on a new connection)

rclarey and others added 3 commits September 16, 2026 13:41
Adds a hidden `databricks ssh agent-shim <agent>` command that runs on the
serverless driver: it probes the workspace AI Gateway, sets up PATH, and
execs a ucode-configured coding agent (Claude Code, Codex) with the
Databricks session context injected.

This first commit assumes the toolchain (uv/ucode/node) is already present
on the driver; first-run bootstrap of that toolchain follows in the next
commit.

Co-authored-by: Isaac <no-reply@databricks.com>
On first launch the shim now installs the toolchain the agent needs before
execing ucode: uv (into ~/.local/bin), a pinned ucode release via uv, and
Node/npm (downloaded and SHA256-verified) when the serverless image ships
none. It also silences npm's update-notifier and records the resolved PATH
so later launches skip straight to the agent.

Co-authored-by: Isaac <no-reply@databricks.com>
@github-actions

Copy link
Copy Markdown
Contributor

Waiting for approval

Based on git history, these people are best suited to review:

  • @anton-107 -- recent work in experimental/ssh/internal/client/, experimental/ssh/cmd/

Eligible reviewers: @andrewnester, @denik, @janniklasrose, @lennartkats-db, @pietern, @renaudhartert-db, @rugpanov, @shreyas-goenka, @simonfaltum

Suggestions based on git history. See OWNERS for ownership rules.

@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 174c5e5

Run: 35234688514

Env ✅​pass 🙈​skip Time
✅​ aws linux 276 15 4:14
✅​ aws windows 278 13 5:18
✅​ azure linux 275 15 4:03
✅​ azure windows 277 13 6:49
✅​ gcp linux 276 15 4:20
✅​ gcp windows 278 13 6:00
Top 3 slowest tests (at least 2 minutes):
duration env testname
6:46 azure windows TestAccept
5:57 gcp windows TestAccept
5:14 aws windows TestAccept

if workspace != "" {
argv = append(argv, "--workspace", workspace)
}
argv = append(argv, contextArgs...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these meant to be passed to real "claude" and "codex" binaries? if so, don't you need to prepend these with an additional "--" so that they get passed to them instead of to ug?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ug passes along arguments it doesn't understand, so this works today but you're right I'll add -- to make this more robust 👍

return nil, fmt.Errorf("failed to acquire setup lock: %w", err)
}
// Held by another process: reclaim it if stale, otherwise wait and retry.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > setupLockStaleAfter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock becomes “stale” after two minutes, with no heartbeat or owner-liveness check. A legitimate cold installation exceeding that duration lets a second session enter setup concurrently. Worse, the original owner’s unconditional unlock subsequently deletes the second owner’s lock. This defeats the serialization protecting shared installation directories. Suggestion: use an OS-backed lock released on process exit rather than treating elapsed time as proof of death.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no OS-backed lock in go's standard library (or any of the CLI's current dependencies AFAIK), so this is modeled after the existing lock here:

func tryAcquireRefreshLock(ctx context.Context) (func(), bool) {

}
return []string{agent.contextFlag, f}, nil
case agent.contextHomeFile != "":
f := filepath.Join(home, agent.contextHomeFile)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shim writes instructions to ~/.codex, while preserving any inherited CODEX_HOME. Pinned UG also writes its Codex configuration to the hardcoded default directory. Codex then reads a different directory, missing both the injected instructions and UG’s profile.

Suggestion: make the shim and upstream configuration honor the same home, or reject the unsupported override early.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite follow. You're saying this shim and ug both don't support CODEX_HOME? ug passes a --profile flag to codex to explicitly load the ug managed configuration, I'm not sure how that interacts with CODEX_HOME though.

Either way I'll create a ticket to follow up, but I think this is out of scope for this PR

if err := client.Config.Authenticate(req); err != nil {
return nil, fmt.Sprintf("network error: %v", err)
}
resp, err := (&http.Client{Transport: client.Config.HTTPTransport, Timeout: 10 * time.Second}).Do(req)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constructing a new http.Client with Config.HTTPTransport is not equivalent to using the configured SDK client: the transport is normally nil, and SDK configuration is applied elsewhere. A configured SDK request succeeds against a local TLS fixture while this preflight rejects the same endpoint because it ignores the configured TLS setting. The hardcoded timeout likewise bypasses SDK timeout configuration.

Question: can we use the configured SDK request path and retain workspace-routing headers?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model services endpoint is supported in the SDK but the legacy endpoint is not. I'll see if I can reuse the HTTP client the SDK uses to manually make the legacy request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants