Conversation
A challenge asking only for a more recent login carries a max age, which was passed to the shell argument escaper as an integer where a string is required. The CLI exited with a TypeError instead of prompting the user to log in again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new SSH key API methods introduce inconsistent error handling (raw BadResponseException) compared to established ApiResponseException wrapping, which should be aligned before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR migrates the legacy CLI’s SSH key management from the older account/SSH-key representation to a newer SSH keys API, updating the CLI to use SHA-256 (OpenSSH-style) fingerprints and a new SSH key model.
Changes:
- Added a dedicated
Platformsh\Cli\Model\SshKeymodel and updated commands/services to use new fields (id,label,sha256). - Implemented new SSH key API methods in
Api(list/get/add/delete) including pagination handling and cache invalidation. - Updated fingerprint calculation to return OpenSSH-style
SHA256:fingerprints and added PHPUnit coverage for the fingerprint behavior.
File summaries
| File | Description |
|---|---|
| legacy/tests/Service/SshKeyTest.php | Adds tests for SHA-256/OpenSSH fingerprint generation and invalid key handling. |
| legacy/src/Service/SshKey.php | Switches account-key matching from MD5 to SHA-256/OpenSSH fingerprint format. |
| legacy/src/Service/Api.php | Adds SSH keys API endpoints (list/get/add/delete), pagination, and dedicated caching. |
| legacy/src/Model/SshKey.php | Introduces a CLI-level SSH key model for the new API representation. |
| legacy/src/Event/LoginRequiredEvent.php | Ensures login option values are consistently stringified for shell escaping. |
| legacy/src/Command/SshKey/SshKeyListCommand.php | Updates displayed/output columns to match new key fields (label, sha256). |
| legacy/src/Command/SshKey/SshKeyDeleteCommand.php | Updates deletion flow to use new key IDs and new API delete operation. |
| legacy/src/Command/SshKey/SshKeyAddCommand.php | Updates add flow to use new API and handles “already registered” (409) response. |
| legacy/phpstan-baseline.neon | Removes a baseline entry that is no longer applicable after key-ID typing changes. |
Review details
Suppressed comments (2)
legacy/src/Service/Api.php:994
- In getSshKey(), non-404 failures are rethrown as BadResponseException. For consistency with the rest of the API layer (and to preserve the richer error details formatting), it should rethrow ApiResponseException::create(...) instead.
if ($e->getResponse()->getStatusCode() === 404) {
return null;
}
throw $e;
}
legacy/src/Service/Api.php:1027
- deleteSshKey() currently lets BadResponseException bubble up directly. To keep error handling consistent with other direct HTTP calls, catch BadResponseException and rethrow ApiResponseException::create(...).
$this->getHttpClient()->request('DELETE', $this->sshKeysUrl() . '/' . rawurlencode($id));
$this->clearSshKeysCache();
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
📋 PR Summary This PR moves the SSH key commands ( Changes
Flow Diagramflowchart TD
A[SSH key call] --> B{getSshKeySource}
B -->|auth| C[/users/id/ssh-keys paginated/]
B -->|accounts or 404| D[/me and /ssh_keys/]
C --> E[SshKey::fromData]
D --> F[SshKey::fromLegacyData]
|
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 3 minor points
🔍 Full review · 9 files reviewed
🔍 What this review checked
- The test vector's expected fingerprint SHA256:Zc8rf0C3ZFAVs8mnWl4r6jKmJN8kutsoBK2h4UkXPp8 is the correct unpadded base64 SHA-256 of that key blob (recomputed independently).
- No references to the old fields ($key->title, ->fingerprint, ->key_id) or to Platformsh\Client\Model\SshKey remain, and getLegacyAccountInfo has no leftover callers.
- addSshKey() and deleteSshKey() both invalidate the new session-scoped 'ssh-keys' cache key, and both commands re-warm it with getSshKeys(true).
- The removed phpstan-baseline entry is the only baseline entry referencing SshKey code, so no baseline entry is left unmatched.
- The is_array() cache check makes a cached empty key list a hit rather than a miss, unlike the old truthiness check.
Verification. The diff adds legacy/tests/Service/SshKeyTest.php covering only getPublicKeyFingerprint (valid and invalid key); nothing covers the new Api::getSshKeys pagination/caching, getSshKey 404 handling, addSshKey 409 handling or deleteSshKey. The legacy-php job in .github/workflows/ci.yml runs php-cs-fixer, phpstan (level 8 + baseline) and ./scripts/test/unit.sh over these files.
Review details
- Commit: 8672834
- Model: claude-opus-5
🔵 Minor points
legacy/src/Service/Api.php:988— The new SSH key methods let raw Guzzle exceptions escape, unlike every other direct HTTP call in this codebase (e.g.Api::getTasks()at line ~1914,TaskRunCommand,TeamUserAddCommand, which all dothrow ApiResponseException::create($e->getRequest(), $e->getResponse(), $e)).getSshKey()maps only 404 to null; combined with the removal of theis_numeric($id)guard in SshKeyDeleteCommand,ssh-key:delete <malformed-id>now sends the ID to the API and a 400/422 response surfaces as an unhandledGuzzleHttp\Exception\ClientExceptionwith a truncated-body message instead of "SSH key not found" or a formatted API error. The same applies togetSshKeys(),addSshKey()(non-409 errors) anddeleteSshKey().legacy/src/Service/Api.php:959— The pagination loop ingetSshKeys()has no page limit and no guard against a repeated URL: it follows_links.next.hrefunconditionally, so if the API returns anextlink on the last page that resolves to the URL just requested (a common pattern for APIs that always emitnext), the loop issues the same GET forever while appending duplicate items to$items, hanging the command and growing memory without bound.legacy/src/Model/SshKey.php:22—SshKey::$activeis parsed from the API but never read anywhere:Api::getSshKeys()returns inactive keys,SshKeyListCommandhas noactivecolumn, andService\SshKey::listAccountKeyFingerprints()feeds inactive keys' fingerprints intofindIdentityMatchingPublicKeys(). A user whose only account key is inactive gets it auto-selected as the SSH IdentityFile byselectIdentity()and is told byWelcomeCommand/SshDiagnosticsthat a local key matching the account exists, while SSH authentication fails with no explanation.
Review 1 of 10 for this pull request · View the full run
8672834 to
488f1d4
Compare
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 2 minor points
🔁 Incremental · 5 files reviewed
🔍 What this review checked
ApiResponseException::wrapGuzzleException()exists in platformsh/client at the locked revision and recreates the exception withget_class($e), soSshKeyAddCommand'scatch (BadResponseException)still matches wrapped client errors.addSshKey()deliberately rethrows the raw 409 so the command's duplicate-key message still fires, while all other statuses get API error details appended.- The circular-pagination guard keys
$visitedUrlson the resolved absolute URL, so a relativenexthref pointing back at the first page is detected. - The anonymous test subclass matches
Api::__construct(config, cache, output, io, ...)and thegetHttpClient(): ClientInterface/getMyUserId(bool): stringsignatures it overrides. listAccountKeyFingerprints()'array_filterresult keeps string values only, soin_array()infindIdentityMatchingPublicKeys()is unaffected by the non-list keys.
Verification. The diff adds legacy/tests/Service/ApiSshKeyTest.php (pagination, 404→null, error details, cache invalidation) and one SshKeyTest case, run by the legacy-php CI job via ./scripts/test/unit.sh alongside make lint-phpstan/make lint-php-cs-fixer; note the new SshKeyTest case does not actually exercise the inactive-key filter (see finding), and no test covers SshKeyListCommand's new active column.
Review 2 of 10 for this pull request · View the full run
SSH keys are now managed at /users/{id}/ssh-keys. The new API identifies a key
by a string ID rather than a number, names its label "label" rather than
"title", and reports a SHA-256 fingerprint in OpenSSH format rather than an
MD5 one.
Fingerprints are computed the same way here, so a local key is still matched
to the one on your account. The ssh-key:list columns are renamed to match, and
ssh-key:add keeps its --name option. ssh-key:delete accepts the new IDs, which
the previous check for a numeric argument rejected. Adding a key that is
already registered is now reported rather than passing silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
488f1d4 to
a9e4892
Compare
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 2 minor points
🔁 Incremental · 4 files reviewed
🔍 What this review checked
- Removing the
$key->active ?guard in SshKeyListCommand makes the local-path lookup run for inactive keys, so a matching~/.sshkey is now shown instead of 'Not found'. activewas added to both$tableHeaderand$defaultColumns, so the new column renders and is selectable via --columns.- The circular-pagination test's
nexthref/api/users/user-id/ssh-keysresolves to exactly the first URL built bysshKeysUrl()from basehttps://api.example.test/api, so the RuntimeException is really triggered. PLATFORMSH_CLI_HOME/PLATFORMSH_CLI_API_URL/PLATFORMSH_CLI_SESSION_IDmatchapplication.env_prefixin legacy/config.yaml, and the added assertions fail loudly if the overrides stop being honoured.- SshKeyListCommandTest can construct the command without a container:
CommandBase::run()never touches the unset private$config, and readonly services likeSshKeyare already doubled elsewhere in the suite.
Verification. This increment adds legacy/tests/Command/SshKey/SshKeyListCommandTest.php covering the inactive-key row and path, and tightens the env overrides in ApiSshKeyTest/SshKeyTest; all run in the legacy-php CI job (./scripts/test/unit.sh, plus phpstan and php-cs-fixer lint steps) on ubuntu-latest only.
🔵 Minor points
legacy/tests/Service/SshKeyTest.php:84—assertSame($this->tempDir, $config->getHomeDirectory())compares the rawtempnam()path againstConfig::getHomeDirectory(), which returnsrealpath($value).HasTempDirTrait::tempDirSetUp()builds the directory undersys_get_temp_dir(), which on macOS is/var/folders/...— a symlink to/private/var/folders/.... The assertion therefore fails on any platform where the temp dir path contains a symlink (macOSmake testlocally), even though the underlying behaviour is correct; the GitHub Actionslegacy-phpjob runs on ubuntu-latest and does not exercise this. Comparingrealpath($this->tempDir)would be portable.legacy/tests/Service/SshKeyTest.php:87—testInactiveAccountKeysAreNotMatcheddepends onSshKey::listPublicKeys(), whose result is held in a method-levelstatic $publicKeyListshared by everySshKeyinstance in the PHP process. The test only proves theactivefilter because no earlier test in the suite calls a realSshKey::findIdentityMatchingPublicKeys()/hasLocalKey(); as soon as one does (PHPUnit runs all test classes in one process, without process isolation), the glob of$tempDir/.sshis skipped,hasLocalKey()returns false for the wrong reason and the test passes even if thearray_filter(..., fn($k) => $k->active)is deleted.
Review 3 of 10 for this pull request · View the full run
Resolve the PHPStan baseline conflict with main's level 9 upgrade by taking main's baseline, and fix the new level 9 errors in this branch instead of adding them to the baseline: - Narrow the ssh-key:add --name option and ssh-key:delete ID argument to strings. - Read string fields in the SshKey model without casting mixed values. - Type LoginRequiredEvent::getLoginOptions() precisely. - Avoid an always-true assertion in ApiSshKeyTest. Remove baseline entries that no longer match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 2 minor points · 2 still open
🔍 Full review · 11 files reviewed
Outstanding from earlier reviews:
- 🔵
legacy/tests/Service/SshKeyTest.php:84: New assertion fails for macOS developers despite correct behaviour. — Line 84 still assertsassertSame($this->tempDir, ...)againstgetHomeDirectory(), which returnsrealpath($value)(Config.php:216), so it fails where the temp dir contains a symlink. - 🔵
legacy/tests/Service/SshKeyTest.php:87: A future test can silently turn this regression guard into a no-op. —listPublicKeys()still uses a method-levelstatic $publicKeyListand the test callshasLocalKey()without any reset, so an earlier glob in the same process masks the assertion.
Verification
- Relative
nexthrefs are resolved against the request URL via UriResolver, so?page=2correctly becomes the collection URL plus the query. addSshKey()anddeleteSshKey()clear exactly the cache keygetSshKeys()reads ({sessionId}:ssh-keys), so the delete command's follow-upgetSshKeys(true)sees the mutation.- No reference to the old
Platformsh\Client\Model\SshKeyproperties (key_id,title,fingerprint) remains anywhere under legacy/src. getPublicKeyFingerprint()emitsSHA256:+ unpadded base64, the same shape the model'ssha256field carries, and both comparison sites use that field.- The removed phpstan-baseline entries match the code the diff actually typed (LoginRequiredEvent's
(string) $value,choose()receivingarray<string,string>).
The diff adds three PHPUnit test files (ApiSshKeyTest covering pagination, caching, 404-to-null and cache invalidation; SshKeyTest covering fingerprint format and the active filter; SshKeyListCommandTest covering the new row shape), and the legacy-php CI job runs php-cs-fixer, phpstan (level 9 with the updated baseline) and the PHPUnit suite over them. Nothing covers the stale-cache duplicate check in ssh-key:add or the 409 branch of that command.
Review details
- Commit: ec2d1ea
- Model: claude-opus-5
Review 4 of 10 for this pull request · View the full run
Conflicts resolved: - SshKeyAddCommand/SshKeyDeleteCommand: keep the new SSH key API calls and 409 handling, using the typed Argument/Option input helpers from main. - phpstan-baseline.neon: take main's baseline and drop entries this branch no longer triggers. Main raised PHPStan to level 10, so the new SSH key code in Api.php is tightened: page items and the "next" link are type-checked, and SshKey::fromData() accepts any array since it validates each field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 5 still open
🔍 Full review · 11 files reviewed
Outstanding from earlier reviews:
- 🟡 #4098231556 —
legacy/src/Command/SshKey/SshKeyAddCommand.php:173: ssh-key:add silently no-ops for up to 10 minutes after an out-of-band deletion. —keyExistsByFingerprint()still calls$this->api->getSshKeys()without reset, so it reads the list cached for 600s. - 🔵 #4098231566 —
legacy/src/Service/Api.php:964: A benign always-present next link turns key listing into a hard error. — On a revisited URL,getSshKeys()still throws 'circular pagination link' and discards the items already collected. - 🔵 #4098231576 —
legacy/src/Command/SshKey/SshKeyAddCommand.php:174: User sees contradictory messages and cannot tell why SSH auth still fails. —keyExistsByFingerprint()still matches onsha256only and ignoresactive. - 🔵
legacy/tests/Service/SshKeyTest.php:84: New assertion fails for macOS developers despite correct behaviour. — SshKeyTest line 84 still compares the raw tempDir againstgetHomeDirectory(), which returnsrealpath($value). (first raised) - 🔵
legacy/tests/Service/SshKeyTest.php:87: A future test can silently turn this regression guard into a no-op. —listPublicKeys()still keeps a method-levelstatic $publicKeyList, andhasLocalKey()calls it without reset. (first raised)
Verification
Config::getHomeDirectory()andgetEnv()read the injected env array under thePLATFORMSH_CLI_prefix, so the new tests'PLATFORMSH_CLI_HOME/API_URL/SESSION_IDoverrides take effect.SshKeyListCommandnow callsfindIdentityMatchingPublicKeysfor inactive keys too, andactiveis one of the default columns.- After the migration, no code in
legacy/srcreads the oldfingerprint,key_id,titleorssh_keysSshKey fields or the removedClient\Model\SshKey. addSshKeyanddeleteSshKeycallclearSshKeysCache()only after a successful request, andlogout()still runsflushAll(), which also clears the new{session}:ssh-keysentry.
The new PHPUnit tests (ApiSshKeyTest, SshKeyTest, SshKeyListCommandTest) cover pagination, caching, cache clearing after add/delete, 404 handling, SHA-256 fingerprints and inactive-key filtering. They run in the legacy-php job in .github/workflows/ci.yml (unit.sh, alongside phpstan). No test covers the 409 path in SshKeyAddCommand or SshKeyDeleteCommand.
Review details
- Commit: 8b4bccf
- Model: claude-opus-5-5
Review 5 of 10 for this pull request · View the full run
The duplicate check read the cached key list, so a key deleted elsewhere within the cache TTL was reported as already existing and never added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adding a key that is already in the account but inactive printed "This key already exists" and exited successfully, although the key cannot be used for SSH. It now says the key is inactive and exits 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "next" link pointing at the page just fetched was treated as a circular link, failing the whole listing. It now ends pagination. A link back to an earlier page is still rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment said links may be relative to the API base URL, but they are resolved against the request URL (RFC 3986), as in the API client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No new issues found · 2 still open
🔁 Incremental · 4 files reviewed
Outstanding from earlier reviews:
- 🔵
legacy/tests/Service/SshKeyTest.php:84: New assertion fails for macOS developers despite correct behaviour. — SshKeyTest still assertsassertSame($this->tempDir, $config->getHomeDirectory()), comparing the raw path with the realpath'd one. (first raised) - 🔵
legacy/tests/Service/SshKeyTest.php:87: A future test can silently turn this regression guard into a no-op. —SshKey::listPublicKeys()still keeps its list in a method-levelstatic $publicKeyList, so the test depends on test order. (first raised)
Verification
findKeyByFingerprint()now callsgetSshKeys(true), so the duplicate check always reads from the API and never from the 600s cache.- When the matching account key is inactive,
ssh-key:addnow prints an 'inactive' error and returns 1 without callingaddSshKey. getSshKeys()now stops when the resolvednextURL equals the page just fetched. It still throws only when a link revisits an earlier page.- In
testRejectsCircularPagination, the second page links back to the first page's URL, so the test exercises the revisit branch and not the self-link branch.
This increment adds SshKeyAddCommandTest (cache bypass, inactive-key and active-key paths) and ApiSshKeyTest::testStopsAtANextLinkToTheCurrentPage, and updates testRejectsCircularPagination. These tests run in the repository's legacy PHPUnit job. No test covers resolving a next link against a base URL whose path differs from the request path.
Review 6 of 10 for this pull request · View the full run
Accounts still owns SSH keys until the source is switched to Auth, so
the CLI cannot use the Auth API unconditionally. GET /ssh-key-source
reports the current owner, and the SSH key methods now use the legacy
Accounts API (/me and /ssh_keys) or the Auth API
(/users/{id}/ssh-keys) accordingly.
The source is cached per session and refetched whenever the key list
is reset. A 404 is treated as "accounts", so the CLI still works where
the endpoint is not yet deployed. Legacy keys are mapped to the same
model, with a SHA-256 fingerprint computed from the key value, so the
commands and local key matching behave the same with either source.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No new issues found · 2 still open
🔁 Incremental · 4 files reviewed
Outstanding from earlier reviews:
- 🔵
legacy/tests/Service/SshKeyTest.php:84: New assertion fails for macOS developers despite correct behaviour. — SshKeyTest line 84 still asserts assertSame($this->tempDir, $config->getHomeDirectory()) without realpath(), so it fails where the temp dir is behind a symlink. (first raised) - 🔵
legacy/tests/Service/SshKeyTest.php:87: A future test can silently turn this regression guard into a no-op. — SshKey::listPublicKeys() still caches the glob in a method-level static, so the test depends on no earlier test having populated it. (first raised)
Verification
- Legacy-source URLs (
/me,/ssh_keys,/ssh_keys/{id}) usegetApiUrl(), matching PlatformClient::apiUrl() whenever api_url is configured. - clearSshKeysCache() deletes both per-source cache keys, so a source switch cannot serve a stale list from the other source's key.
- Numeric legacy key IDs survive the interactive delete prompt: QuestionHelper::choose() casts the chosen key back to string before getSshKey(string).
- Non-404 errors from getSshKeySource() are wrapped as ApiResponseException (a RequestException/BadResponseException), so ssh-key:add's 409 handling and the propagation test hold.
ApiSshKeyTest gains Guzzle-mock tests with request-history assertions for source fetch/caching, 404→accounts fallback, unknown-source rejection, legacy list/add/delete/get URLs and payloads, and source refetch on reset. The command tests don't cover the legacy source, and nothing covers a 200 non-JSON /ssh-key-source response.
Review 7 of 10 for this pull request · View the full run
Move the SSH key commands (
ssh-keys,ssh-key:add,ssh-key:delete) from the old account-info API to the new SSH keys API at/users/{id}/ssh-keys.Changes
Api::getSshKeys(),getSshKey(),addSshKey()anddeleteSshKey(), which call the new API directly. The key list follows pagination, with limits against circular or runawaynextlinks, and is cached per session. Adding or deleting a key clears the cache.Platformsh\Cli\Model\SshKeymodel for the new representation: the ID is an opaque string (a ULID) rather than an integer, the label replaces the title, and the key has anactiveflag.SHA256:…, as printed byssh-keygen -l) instead of MD5.ssh-keysshows the label and whether each key is active, and the SHA-256 fingerprint is available as a column.ssh-key:addchecks for duplicates against a fresh key list and reports an existing inactive key. If the API returns 409 (the key is already registered by any user), the command shows a specific message.TypeErrorwhen a re-authentication challenge includes a max age.