@thirdfy/agent-cli 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/README.md +4 -4
- package/package.json +2 -2
- package/src/cli/help.mjs +1 -0
- package/src/cli/manifest.mjs +4 -4
- package/src/cli/options/index.mjs +2 -0
- package/src/cli/options/onboarding/credentials.mjs +1 -7
- package/src/cli/options/onboarding/oauthLogin.mjs +8 -0
- package/src/commands/account/credits.mjs +9 -7
- package/src/commands/discovery.mjs +6 -5
- package/src/commands/onboarding/bootstrap.mjs +35 -0
- package/src/commands/onboarding/credentials.mjs +0 -10
- package/src/commands/onboarding/oauthLogin.mjs +284 -0
- package/src/core/context.mjs +16 -6
- package/src/core/errorEnvelope.mjs +6 -0
- package/src/core/http.mjs +47 -2
- package/src/core/oauth/authServer.mjs +192 -0
- package/src/core/oauth/pkce.mjs +17 -0
- package/src/core/oauth/session.mjs +117 -0
- package/src/lib/creditsUsdSummary.mjs +128 -29
- package/src/runtime/execution/payloads.mjs +6 -0
- package/src/runtime/handlers.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@thirdfy/agent-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## [0.5.0] - 2026-10-15
|
|
6
|
+
|
|
7
|
+
`thirdfy-agent login` signs in with OAuth in the browser (auth.thirdfy.com, the same sign-in the MCP uses), as one agent with read access, plus trade with `--trade`. A minor version because `login` without a token flag now starts that sign-in instead of failing, and `credits balance` / `pricing quote` drop their credit counts for USD. `login email`, token flags and agent API keys work as before.
|
|
8
|
+
|
|
9
|
+
### Removed
|
|
10
|
+
|
|
11
|
+
- **The ignored Bitfinex flags of `credentials upsert`** (`--permission-profile`, `--verify-permissions`, `--skip-permissions-probe`, `--bitfinex-base-url`), as 0.4.3's warning announced (THI-86). Passing one is now an unknown-option error. Drop them from scripts.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`thirdfy-agent login` signs in with OAuth** (THI-107, part of THI-104): auth.thirdfy.com, the same sign-in the MCP uses. With no token flag, `login` does this:
|
|
16
|
+
- opens the browser (`--no-browser` prints the URL);
|
|
17
|
+
- the owner picks one agent and approves read (plus trade with `--trade`);
|
|
18
|
+
- the browser returns to a one-time `127.0.0.1` listener;
|
|
19
|
+
- the CLI is a public client (registered once per server), and uses PKCE S256 and `resource` = the API.
|
|
20
|
+
|
|
21
|
+
How the sign-in is used:
|
|
22
|
+
- **Credential:** it's stored in `auth.oauth` and becomes the agent credential: after `--agent-api-key` and `THIRDFY_AGENT_API_KEY`, before config `agent.apiKey` and before stored owner auth for credits reads.
|
|
23
|
+
- **Agent:** its agent comes from the API (`GET /api/v1/agent/oauth/session`).
|
|
24
|
+
- **Requests:** they send `Authorization: Bearer`, refresh before expiry (rotation), and refresh and retry once after a 401.
|
|
25
|
+
- **Expiry:** a refused refresh is `OAUTH_SESSION_EXPIRED` (`nextAction: reauthenticate`).
|
|
26
|
+
- **`logout`** revokes the sign-in at the auth server.
|
|
27
|
+
- **`whoami`** shows `auth.oauth`.
|
|
28
|
+
- New next actions: `reauthenticate`, `reauthenticate_with_trade`.
|
|
29
|
+
|
|
30
|
+
Needs thirdfy/thirdfy-auth#15 (the API as a resource, token introspection) and the API change THI-106. Until both are live, `login` stops at `OAUTH_RESOURCE_NOT_SUPPORTED` and points to `login email`. `login email` and token flags work as before.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **`credits balance` and `pricing quote` show balances in USD, never credits** (THI-123; same rules as thirdfy/thirdfy-mcp#279). Credits are Thirdfy's internal unit, and no price per action or request is stated.
|
|
35
|
+
- **Kept:** `availableUsd`, the free request counts (`freeDailyLimit`, `freeRequestsRemainingToday`, ...), `wouldBePaidBy` and `displayCurrency`.
|
|
36
|
+
- **Owner's fallback:** `ownerCreditsFallback` gets `availableUsd`, at the API's own `retailUsdPerCredit` from the same response, rounded to cents.
|
|
37
|
+
- **Dropped:** every credit count, per-credit rate and per-action price (`balanceCredits`, `balance`, `totalEarned`, `totalSpent`, `creditUsdValue`, `retailUsdPerCredit`, `quote.creditsCost`, `chargedUsd`, ...).
|
|
38
|
+
- **Text output:** without `--json`, `credits balance` reads `Balance loaded: $4.20 left; 8 of 20 free requests left today; then the owner's balance ($86.22) pays; then top up the balance.` The `note` says the same in USD. `pricing quote` reads `Action checked; would be paid by the owner's balance (owner_credits)`.
|
|
39
|
+
- **Unchanged:** the owner reports `credits transactions`, `credits usage`, `models list` and `models estimate` (credit ledger lines), as the MCP left its owner-token credits tools.
|
|
40
|
+
- **Owner ledger:** `totalAvailable` (a credit count) and `currency: 'credits'` are dropped too, and the owner route's free counts (`freeAllowance`, `freeRemainingToday`) are read, so owner auth also prints the free requests left.
|
|
41
|
+
- Before (0.4.3 and earlier) `credits balance` returned only `balanceCredits` and `ownerCreditsFallback.balanceCredits`, so a model answered in credits.
|
|
42
|
+
- **A proper "signed in" page after `thirdfy-agent login`.** The local listener used to answer the browser with a bare, unbranded page. Like Claude Code, GitHub CLI and Google Cloud CLI, it now:
|
|
43
|
+
- holds the browser until the CLI has its tokens and knows its agent;
|
|
44
|
+
- then sends it to the sign-in server's result page, `<issuer>/cli/signed-in`, with the agent, read or trade access, and the API (auth.thirdfy.com 0.5.1+);
|
|
45
|
+
- on a cancelled or failed sign-in, sends it to `<issuer>/cli/error`.
|
|
46
|
+
|
|
47
|
+
A sign-in server without those pages gets a built-in page in the same style.
|
|
48
|
+
- **A sign-in is used only on the API it was issued for**: an api-test sign-in is never sent to production, or the reverse. `whoami` shows `auth.oauth.resource` and `activeForThisApi`.
|
|
6
49
|
|
|
7
50
|
## [0.4.3] - 2026-10-14
|
|
8
51
|
|
package/README.md
CHANGED
|
@@ -39,11 +39,11 @@ Run without global install:
|
|
|
39
39
|
npx @thirdfy/agent-cli --help
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
## What's new in v0.
|
|
42
|
+
## What's new in v0.5.0
|
|
43
43
|
|
|
44
|
-
- **
|
|
45
|
-
- **
|
|
46
|
-
- **
|
|
44
|
+
- **Sign in with your Thirdfy account:** `thirdfy-agent login` opens your browser at auth.thirdfy.com. You pick one agent and approve read access, and trade access with `thirdfy-agent login --trade`. The CLI then acts as that agent, refreshes the sign-in by itself, and `thirdfy-agent logout` revokes it. Disconnect it any time in Connected apps at auth.thirdfy.com.
|
|
45
|
+
- **Same sign-in as the MCP:** the same consent page and the same Connected apps list as Claude, ChatGPT and Cursor.
|
|
46
|
+
- **Nothing else changes:** `login email`, `--auth-token`, `--owner-session-token` and agent API keys work as before, and an explicit `--agent-api-key` always wins over the sign-in.
|
|
47
47
|
- Older versions: see [CHANGELOG.md](./CHANGELOG.md).
|
|
48
48
|
|
|
49
49
|
## Quick start
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thirdfy/agent-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Thirdfy Agent CLI for onboarding, governance preflight, execute-intent, and status polling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"test": "node --test test/*/*.test.cjs test/*/*/*.test.cjs",
|
|
26
|
-
"lint": "node --check ./bin/thirdfy-agent.mjs && node --check ./src/core/*.mjs && node --check ./src/core/execution/*.mjs && node --check ./src/commands/*.mjs && node --check ./src/commands/venues/*.mjs && node --check ./src/commands/onboarding/*.mjs && node --check ./src/commands/account/*.mjs && node --check ./src/commands/signals/*.mjs && node --check ./src/cli/*.mjs && node --check ./src/cli/options/*.mjs && node --check ./src/cli/options/venues/*.mjs && node --check ./src/cli/options/onboarding/*.mjs && node --check ./src/cli/options/account/*.mjs && node --check ./src/runtime/*.mjs && node --check ./src/runtime/execution/*.mjs && node --check ./src/runtime/execution/runners/*.mjs && node --check ./src/runtime/actions/*.mjs && node --check ./src/runtime/validation/*.mjs && node --check ./src/runtime/certification/*.mjs && node --check ./src/runtime/providerHints/*.mjs && node --check ./src/runtime/risk/*.mjs",
|
|
26
|
+
"lint": "node --check ./bin/thirdfy-agent.mjs && node --check ./src/core/*.mjs && node --check ./src/core/execution/*.mjs && node --check ./src/core/oauth/*.mjs && node --check ./src/commands/*.mjs && node --check ./src/commands/venues/*.mjs && node --check ./src/commands/onboarding/*.mjs && node --check ./src/commands/account/*.mjs && node --check ./src/commands/signals/*.mjs && node --check ./src/cli/*.mjs && node --check ./src/cli/options/*.mjs && node --check ./src/cli/options/venues/*.mjs && node --check ./src/cli/options/onboarding/*.mjs && node --check ./src/cli/options/account/*.mjs && node --check ./src/runtime/*.mjs && node --check ./src/runtime/execution/*.mjs && node --check ./src/runtime/execution/runners/*.mjs && node --check ./src/runtime/actions/*.mjs && node --check ./src/runtime/validation/*.mjs && node --check ./src/runtime/certification/*.mjs && node --check ./src/runtime/providerHints/*.mjs && node --check ./src/runtime/risk/*.mjs",
|
|
27
27
|
"validate:cli-manifest-handlers": "node ./scripts/validate-cli-manifest-handlers.mjs",
|
|
28
28
|
"validate:runtime-boundaries": "node ./scripts/validate-runtime-boundaries.mjs",
|
|
29
29
|
"validate:npm-pack": "node ./scripts/validate-npm-pack.mjs",
|
package/src/cli/help.mjs
CHANGED
|
@@ -7,6 +7,7 @@ Core commands:
|
|
|
7
7
|
thirdfy-agent profile use --profile <name> [--run-mode thirdfy|self|hybrid|agent_wallet] [--custody-mode managed|external] [--json]
|
|
8
8
|
thirdfy-agent profile show [--json]
|
|
9
9
|
thirdfy-agent config set --key <path> --value <value> [--json]
|
|
10
|
+
thirdfy-agent login [--trade] [--no-browser] [--issuer <url>] [--json] (OAuth in the browser: one agent)
|
|
10
11
|
thirdfy-agent login [--auth-token <token> | --owner-session-token <token>] [--agent-api-key <key>] [--agent-key <key>] [--run-mode <mode>] [--custody-mode managed|external] [--wallet-address <addr>] [--json]
|
|
11
12
|
thirdfy-agent login email <address> [--code <otp>] [--accept-terms] [--key-name <name>] [--agent-key <key>] [--not-interactive|--ni] [--json]
|
|
12
13
|
thirdfy-agent logout [--all] [--clear-agent-key] [--json]
|
package/src/cli/manifest.mjs
CHANGED
|
@@ -47,8 +47,8 @@ export const CLI_MANIFEST = [
|
|
|
47
47
|
{
|
|
48
48
|
path: ['login'],
|
|
49
49
|
handler: 'commandLogin',
|
|
50
|
-
options: ['auth', 'bootstrap'],
|
|
51
|
-
description: '
|
|
50
|
+
options: ['auth', 'bootstrap', 'oauthLogin'],
|
|
51
|
+
description: 'Sign in with OAuth in the browser (one agent; --trade for writes), or with an auth token, owner session or agent API key',
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
path: ['login', 'email'],
|
|
@@ -212,7 +212,7 @@ export const CLI_MANIFEST = [
|
|
|
212
212
|
handler: 'commandCreditsBalance',
|
|
213
213
|
tier: 'online',
|
|
214
214
|
options: ['auth'],
|
|
215
|
-
description: '
|
|
215
|
+
description: 'Balance in USD, free requests today and who pays next',
|
|
216
216
|
},
|
|
217
217
|
{
|
|
218
218
|
path: ['credits', 'buy'],
|
|
@@ -254,7 +254,7 @@ export const CLI_MANIFEST = [
|
|
|
254
254
|
handler: 'commandPricingQuote',
|
|
255
255
|
tier: 'online',
|
|
256
256
|
options: ['auth', 'execution'],
|
|
257
|
-
description: '
|
|
257
|
+
description: 'Check who would pay for a catalog action now (never a price)',
|
|
258
258
|
},
|
|
259
259
|
{
|
|
260
260
|
path: ['chat', 'send'],
|
|
@@ -6,6 +6,7 @@ import { attachTrackingOptions } from './tracking.mjs';
|
|
|
6
6
|
import { attachWalletOptions } from './account/wallet.mjs';
|
|
7
7
|
import { attachBootstrapOptions, attachOnboardingHelpOptions } from './onboarding/bootstrap.mjs';
|
|
8
8
|
import { attachCredentialsOptions } from './onboarding/credentials.mjs';
|
|
9
|
+
import { attachOAuthLoginOptions } from './onboarding/oauthLogin.mjs';
|
|
9
10
|
import { attachAnalyticsOptions } from './analytics.mjs';
|
|
10
11
|
import { attachPolymarketOptions } from './venues/polymarket.mjs';
|
|
11
12
|
import { attachHummingbotOptions } from './venues/hummingbot.mjs';
|
|
@@ -27,6 +28,7 @@ const BUNDLES = {
|
|
|
27
28
|
tracking: attachTrackingOptions,
|
|
28
29
|
wallet: attachWalletOptions,
|
|
29
30
|
bootstrap: attachBootstrapOptions,
|
|
31
|
+
oauthLogin: attachOAuthLoginOptions,
|
|
30
32
|
onboardingHelp: attachOnboardingHelpOptions,
|
|
31
33
|
credentials: attachCredentialsOptions,
|
|
32
34
|
analytics: attachAnalyticsOptions,
|
|
@@ -7,11 +7,5 @@ export function attachCredentialsOptions(cmd) {
|
|
|
7
7
|
.option('--credential-type <type>', 'credential type')
|
|
8
8
|
.option('--passphrase <value>', 'exchange passphrase')
|
|
9
9
|
.option('--metadata <json>', 'credential metadata JSON')
|
|
10
|
-
.option('--policy <json>', 'credential policy JSON')
|
|
11
|
-
// Accepted and ignored since Bitfinex support was removed, so older scripts keep working; the
|
|
12
|
-
// command says so in meta.warnings. Drop them in the next minor version.
|
|
13
|
-
.option('--permission-profile <profile>', 'ignored (Bitfinex support was removed)')
|
|
14
|
-
.option('--verify-permissions', 'ignored (Bitfinex support was removed)')
|
|
15
|
-
.option('--skip-permissions-probe', 'ignored (Bitfinex support was removed)')
|
|
16
|
-
.option('--bitfinex-base-url <url>', 'ignored (Bitfinex support was removed)');
|
|
10
|
+
.option('--policy <json>', 'credential policy JSON');
|
|
17
11
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** `thirdfy-agent login` with OAuth (auth.thirdfy.com), the default when no token flag is passed. */
|
|
2
|
+
export function attachOAuthLoginOptions(cmd) {
|
|
3
|
+
return cmd
|
|
4
|
+
.option('--trade', 'also ask for trade access (writes that move funds); read-only otherwise')
|
|
5
|
+
.option('--no-browser', 'print the sign-in URL instead of opening the browser')
|
|
6
|
+
.option('--issuer <url>', 'sign-in server (default: paired with --api-base, https://auth.thirdfy.com)')
|
|
7
|
+
.option('--login-timeout <seconds>', 'how long to wait for the browser sign-in (default 300)');
|
|
8
|
+
}
|
|
@@ -6,7 +6,8 @@ import { toAvantisSpelling } from '../../core/venueBrand.mjs';
|
|
|
6
6
|
import {
|
|
7
7
|
formatCreditsEstimateSummary,
|
|
8
8
|
formatCreditsModelsSummary,
|
|
9
|
-
|
|
9
|
+
describeWouldBePaidBy,
|
|
10
|
+
toUsdFirst,
|
|
10
11
|
} from '../../lib/creditsUsdSummary.mjs';
|
|
11
12
|
|
|
12
13
|
function resolveToolCallsEstimate(flags) {
|
|
@@ -146,12 +147,13 @@ export function createCreditsCommands({ printEnvelope, getAuthToken, buildOwnerA
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
/**
|
|
149
|
-
* `pricing quote --action <id>`:
|
|
150
|
-
* (agent_credits | free_request | owner_credits | none) when the API can read the balances
|
|
150
|
+
* `pricing quote --action <id>`: whether one catalog action can run now and who pays for it:
|
|
151
|
+
* `wouldBePaidBy` (agent_credits | free_request | owner_credits | none) when the API can read the balances
|
|
151
152
|
* (thirdfy-api-v2#887). The ledger follows `resolveCreditsLedger`. Agent: `POST /agent/credits/quote`
|
|
152
153
|
* with the agent's key. Owner (session token or auth token): `/credits/pricing/quote`, for one of the
|
|
153
154
|
* owner's agents with --agent-key. An API without the agent route (404, before #887) is quoted
|
|
154
|
-
* through the owner route when owner auth is set and --agent-key names the key's agent.
|
|
155
|
+
* through the owner route when owner auth is set and --agent-key names the key's agent. Never a price:
|
|
156
|
+
* the quote's credit cost and USD figures are dropped (thirdfy-mcp quoteActionPricing, thirdfy-mcp#279).
|
|
155
157
|
*/
|
|
156
158
|
async function commandPricingQuote(ctx, flags) {
|
|
157
159
|
const action = requireFlag(flags, 'action', 'Missing --action for pricing quote');
|
|
@@ -210,9 +212,9 @@ export function createCreditsCommands({ printEnvelope, getAuthToken, buildOwnerA
|
|
|
210
212
|
success: true,
|
|
211
213
|
code: 'OK',
|
|
212
214
|
// wouldBePaidBy (agent_credits | free_request | owner_credits | none), when the API sends it.
|
|
213
|
-
message: response?.wouldBePaidBy ? `Action
|
|
214
|
-
//
|
|
215
|
-
data:
|
|
215
|
+
message: response?.wouldBePaidBy ? `Action checked; would be paid by ${describeWouldBePaidBy(response.wouldBePaidBy)}` : 'Action checked',
|
|
216
|
+
// No price per action or request: no credit cost, rate or USD figure (thirdfy-mcp quoteActionPricing).
|
|
217
|
+
data: toUsdFirst(response),
|
|
216
218
|
meta: {
|
|
217
219
|
apiBase: ctx.apiBase,
|
|
218
220
|
action,
|
|
@@ -2,7 +2,7 @@ import { requireFlag, parseJsonFlag } from '../core/args.mjs';
|
|
|
2
2
|
import { apiGet, apiPost } from '../core/http.mjs';
|
|
3
3
|
import { extractActions, getCachedActionsCatalogSnapshot, resolveCreditsLedger } from '../core/context.mjs';
|
|
4
4
|
import { getEffectiveRunMode } from '../core/runMode.mjs';
|
|
5
|
-
import { creditsFreeQuotaNote, formatCreditsBalanceSummary,
|
|
5
|
+
import { creditsFreeQuotaNote, formatCreditsBalanceSummary, toUsdFirst } from '../lib/creditsUsdSummary.mjs';
|
|
6
6
|
import { filterActionsByChain } from '../runtime/actions/chainFilter.mjs';
|
|
7
7
|
import { pageActionList, readActionListPaging } from '../runtime/actions/actionList.mjs';
|
|
8
8
|
import {
|
|
@@ -186,7 +186,8 @@ async function commandChains(ctx, flags, capabilities) {
|
|
|
186
186
|
* `credits balance`. Owner auth reads the owner's balance (`/credits/balance`). An explicit
|
|
187
187
|
* --agent-api-key, or no owner auth but an agent key in env or config, reads the agent's own ledger
|
|
188
188
|
* (`/agent/credits/balance`: its credits, free requests today and the owner-credits fallback), as
|
|
189
|
-
* thirdfy-mcp getCreditsBalance.
|
|
189
|
+
* thirdfy-mcp getCreditsBalance. In USD, never credits: `availableUsd` (the owner's fallback too, at the
|
|
190
|
+
* API's `retailUsdPerCredit`), free requests and who pays; no credit count, rate or price (thirdfy-mcp#279).
|
|
190
191
|
*/
|
|
191
192
|
async function commandCreditsBalance(ctx, flags, capabilities) {
|
|
192
193
|
const { ledger, agentApiKey } = resolveCreditsLedger(flags);
|
|
@@ -203,9 +204,9 @@ async function commandCreditsBalance(ctx, flags, capabilities) {
|
|
|
203
204
|
printEnvelope({
|
|
204
205
|
success: true,
|
|
205
206
|
code: 'OK',
|
|
206
|
-
message: flags.json ? '
|
|
207
|
-
//
|
|
208
|
-
data: { ...
|
|
207
|
+
message: flags.json ? 'Balance loaded' : `Balance loaded: ${summary}`,
|
|
208
|
+
// USD-first: balances in USD, no credit count, per-credit rate or price (thirdfy-mcp getCreditsBalance).
|
|
209
|
+
data: { ...toUsdFirst(response), ...(note ? { note } : {}) },
|
|
209
210
|
meta: {
|
|
210
211
|
apiBase: ctx.apiBase,
|
|
211
212
|
ledger: agentLedger ? 'agent' : 'owner',
|
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
} from '../../core/context.mjs';
|
|
18
18
|
import { apiGet, apiPost, requestWithRouteFallback } from '../../core/http.mjs';
|
|
19
19
|
import { isEvmAddress, isSolanaAddress } from '../../core/chainRegistry.mjs';
|
|
20
|
+
import { commandOAuthLogin } from './oauthLogin.mjs';
|
|
21
|
+
import { readOAuthSession } from '../../core/oauth/session.mjs';
|
|
22
|
+
import { revokeToken } from '../../core/oauth/authServer.mjs';
|
|
20
23
|
import { createRequire } from 'module';
|
|
21
24
|
|
|
22
25
|
const require = createRequire(import.meta.url);
|
|
@@ -35,6 +38,7 @@ export function createBootstrapCommands(deps) {
|
|
|
35
38
|
verifyOwnerWalletChallenge,
|
|
36
39
|
resolveEffectiveUserDid,
|
|
37
40
|
negotiateCapabilities,
|
|
41
|
+
CLI_VERSION,
|
|
38
42
|
commandAgentRegister,
|
|
39
43
|
} = deps;
|
|
40
44
|
|
|
@@ -232,6 +236,19 @@ async function commandWhoami(ctx, flags) {
|
|
|
232
236
|
hasOwnerSessionToken: Boolean(auth.ownerSessionToken),
|
|
233
237
|
ownerSessionToken: maskSecret(auth.ownerSessionToken),
|
|
234
238
|
ownerSessionExpiresAt: auth.ownerSessionExpiresAt || null,
|
|
239
|
+
oauth: readOAuthSession(config)
|
|
240
|
+
? {
|
|
241
|
+
issuer: auth.oauth.issuer || null,
|
|
242
|
+
// The API this sign-in is for; commands use it only on that API (--api-base).
|
|
243
|
+
resource: auth.oauth.resource || null,
|
|
244
|
+
activeForThisApi: String(auth.oauth.resource || '').replace(/\/+$/, '') === String(ctx.apiBase || '').replace(/\/+$/, ''),
|
|
245
|
+
agentKey: auth.oauth.agentKey || null,
|
|
246
|
+
scope: auth.oauth.scope || '',
|
|
247
|
+
canTrade: auth.oauth.canTrade === true,
|
|
248
|
+
expiresAt: auth.oauth.expiresAt || null,
|
|
249
|
+
accessToken: maskSecret(auth.oauth.accessToken),
|
|
250
|
+
}
|
|
251
|
+
: null,
|
|
235
252
|
},
|
|
236
253
|
agent: {
|
|
237
254
|
hasAgentApiKey: Boolean(agent.apiKey),
|
|
@@ -272,6 +289,12 @@ async function commandLogin(ctx, flags) {
|
|
|
272
289
|
);
|
|
273
290
|
let loginSource = 'token';
|
|
274
291
|
|
|
292
|
+
// No token and no wallet-signature inputs: sign in with OAuth in the browser (THI-104).
|
|
293
|
+
const hasChallengeInputs = Boolean(String(flags.challengeId || '').trim() && String(flags.signature || '').trim());
|
|
294
|
+
if (!authToken && !ownerSessionToken && !hasChallengeInputs) {
|
|
295
|
+
return commandOAuthLogin(ctx, flags, { printEnvelope, version: CLI_VERSION });
|
|
296
|
+
}
|
|
297
|
+
|
|
275
298
|
if (!authToken && !ownerSessionToken) {
|
|
276
299
|
const challengeId = String(flags.challengeId || '').trim();
|
|
277
300
|
const signature = String(flags.signature || '').trim();
|
|
@@ -611,6 +634,17 @@ async function commandLogout(ctx, flags) {
|
|
|
611
634
|
}
|
|
612
635
|
}
|
|
613
636
|
|
|
637
|
+
// An OAuth sign-in is revoked at the auth server (RFC 7009), so its tokens stop working
|
|
638
|
+
// everywhere, not only on this machine. Best effort: local credentials are cleared either way.
|
|
639
|
+
const oauthSession = readOAuthSession(current);
|
|
640
|
+
let oauthRevoked = null;
|
|
641
|
+
if (oauthSession) {
|
|
642
|
+
const revoke = (token) =>
|
|
643
|
+
revokeToken({ revocationEndpoint: oauthSession.revocationEndpoint, clientId: oauthSession.clientId, token }, ctx.timeoutMs || 30_000);
|
|
644
|
+
const [refresh, access] = await Promise.all([revoke(oauthSession.refreshToken), revoke(oauthSession.accessToken)]);
|
|
645
|
+
oauthRevoked = Boolean(refresh || access);
|
|
646
|
+
}
|
|
647
|
+
|
|
614
648
|
const next = { ...current };
|
|
615
649
|
delete next.auth;
|
|
616
650
|
if (flags.clearAgentKey) {
|
|
@@ -626,6 +660,7 @@ async function commandLogout(ctx, flags) {
|
|
|
626
660
|
revokeAttempted,
|
|
627
661
|
revokeSuccess,
|
|
628
662
|
revokeError,
|
|
663
|
+
...(oauthSession ? { oauthRevoked, oauthAgentKey: oauthSession.agentKey || null } : {}),
|
|
629
664
|
configPath: getProfileConfigPath(),
|
|
630
665
|
},
|
|
631
666
|
meta: { apiBase: ctx.apiBase },
|
|
@@ -5,14 +5,6 @@ import { createCliError } from '../../core/envelope.mjs';
|
|
|
5
5
|
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
const { normalizeCredentialStatus } = require('../../utils/cli/delegationNormalization.cjs');
|
|
8
|
-
/** `credentials upsert` flags of the removed Bitfinex permission probe: accepted, ignored, and named in a warning. */
|
|
9
|
-
const REMOVED_BITFINEX_FLAGS = [
|
|
10
|
-
['permissionProfile', '--permission-profile'],
|
|
11
|
-
['verifyPermissions', '--verify-permissions'],
|
|
12
|
-
['skipPermissionsProbe', '--skip-permissions-probe'],
|
|
13
|
-
['bitfinexBaseUrl', '--bitfinex-base-url'],
|
|
14
|
-
];
|
|
15
|
-
|
|
16
8
|
export function createCredentialsCommands({ printEnvelope, getAuthToken, buildOwnerAuthHeaders }) {
|
|
17
9
|
async function commandCredentialsUpsert(ctx, flags, capabilities) {
|
|
18
10
|
const authToken = getAuthToken(flags, 'Missing --auth-token (or THIRDFY_AUTH_TOKEN) for credentials upsert');
|
|
@@ -29,7 +21,6 @@ async function commandCredentialsUpsert(ctx, flags, capabilities) {
|
|
|
29
21
|
if (flags.policy) payload.policy = parseJsonFlag(flags, 'policy');
|
|
30
22
|
if (flags.agentKey) payload.agentKey = String(flags.agentKey).trim();
|
|
31
23
|
if (flags.chainId) payload.chainId = Number(flags.chainId);
|
|
32
|
-
const ignoredFlags = REMOVED_BITFINEX_FLAGS.filter(([key]) => flags[key] !== undefined).map(([, flag]) => flag);
|
|
33
24
|
const response = await requestWithRouteFallback(ctx, {
|
|
34
25
|
method: 'POST',
|
|
35
26
|
routes: ['/api/v1/agent/delegation/credentials/upsert', '/api/v1/agent/agent/delegation/credentials/upsert'],
|
|
@@ -46,7 +37,6 @@ async function commandCredentialsUpsert(ctx, flags, capabilities) {
|
|
|
46
37
|
meta: {
|
|
47
38
|
apiBase: ctx.apiBase,
|
|
48
39
|
capabilitiesVersion: capabilities?.contractVersion || null,
|
|
49
|
-
...(ignoredFlags.length ? { warnings: [`Ignored ${ignoredFlags.join(', ')}: Bitfinex support was removed. Drop these flags; they will stop being accepted in the next minor version.`] } : {}),
|
|
50
40
|
},
|
|
51
41
|
});
|
|
52
42
|
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { createCliError } from '../../core/envelope.mjs';
|
|
4
|
+
import { getProfileConfigPath, loadProfileConfig, persistProfileConfig } from '../../core/context.mjs';
|
|
5
|
+
import {
|
|
6
|
+
buildAuthorizeUrl,
|
|
7
|
+
discoverAuthServer,
|
|
8
|
+
exchangeCode,
|
|
9
|
+
registerCliClient,
|
|
10
|
+
resolveIssuer,
|
|
11
|
+
} from '../../core/oauth/authServer.mjs';
|
|
12
|
+
import { createPkcePair, createState } from '../../core/oauth/pkce.mjs';
|
|
13
|
+
import { sessionFromTokens, writeOAuthSession } from '../../core/oauth/session.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `thirdfy-agent login` with OAuth: the owner signs in at auth.thirdfy.com in the browser, picks
|
|
17
|
+
* one agent, and approves read (always) and trade (`--trade`). The CLI is a public client with a
|
|
18
|
+
* loopback redirect (RFC 8252): a one-shot listener on 127.0.0.1, PKCE S256, `state`, and
|
|
19
|
+
* `resource` = the API base. The token acts as that one agent; the API checks it with the auth
|
|
20
|
+
* server on every request (THI-104).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_LOGIN_TIMEOUT_SECONDS = 300;
|
|
24
|
+
|
|
25
|
+
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
26
|
+
const shortKey = (key) => (key && key.length > 14 ? `${key.slice(0, 6)}…${key.slice(-4)}` : key || '');
|
|
27
|
+
|
|
28
|
+
const FALLBACK_ERRORS = {
|
|
29
|
+
access_denied: ['Sign-in cancelled', 'Nothing was connected. To try again, run <code>thirdfy-agent login</code>.'],
|
|
30
|
+
state_mismatch: ['This sign-in did not come from your terminal', 'For your safety nothing was connected. Start again from your terminal.'],
|
|
31
|
+
issuer_mismatch: ['Unexpected sign-in server', 'Nothing was connected. Start again from your terminal.'],
|
|
32
|
+
exchange_failed: ['The CLI could not finish signing in', 'Nothing was connected. Run <code>thirdfy-agent login</code> to try again.'],
|
|
33
|
+
failed: ['Sign-in not completed', 'Nothing was connected. Run <code>thirdfy-agent login</code> to try again.'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The built-in result page, for a sign-in server without hosted ones: the same look, no assets. */
|
|
37
|
+
function fallbackPage({ ok, title, body, facts = [] }) {
|
|
38
|
+
const rows = facts.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join('');
|
|
39
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><title>${escapeHtml(title)} · Thirdfy</title><style>
|
|
40
|
+
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#000;color:#fafafa;font:15px/1.5 Inter,system-ui,-apple-system,"Segoe UI",sans-serif;padding:16px}
|
|
41
|
+
.card{width:100%;max-width:480px;background:#09090b;border:1px solid #27272a;border-radius:16px;padding:28px 24px 24px}
|
|
42
|
+
.brand{font-weight:700;letter-spacing:.18em;font-size:13px;margin-bottom:20px}.status{display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:1px solid currentColor;margin-bottom:16px;font-size:24px}
|
|
43
|
+
.ok{color:#4ade80;background:rgba(74,222,128,.08)}.err{color:#f87171;background:rgba(248,113,113,.08)}h1{font-size:22px;line-height:1.25;margin:0 0 10px;font-weight:650}p{margin:8px 0;color:#a1a1aa}
|
|
44
|
+
dl{margin:18px 0 0;border:1px solid #27272a;border-radius:12px;overflow:hidden}dl div{display:flex;justify-content:space-between;gap:12px;padding:10px 14px;background:#18181b}dl div+div{border-top:1px solid #27272a}dt{color:#a1a1aa;font-size:14px}dd{margin:0;font-weight:600}
|
|
45
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:#fafafa}</style></head><body><main class="card"><div class="brand">THIRDFY</div>
|
|
46
|
+
<div class="status ${ok ? 'ok' : 'err'}" aria-hidden="true">${ok ? '✓' : '✕'}</div><h1>${escapeHtml(title)}</h1><p>${body}</p>${rows ? `<dl>${rows}</dl>` : ''}</main></body></html>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The browser's last page. Like Claude Code, GitHub CLI and Google Cloud CLI, the listener sends
|
|
51
|
+
* the browser to a result page on the sign-in server (`/cli/signed-in`, `/cli/error`) once the CLI
|
|
52
|
+
* holds its tokens. A server without those pages gets the built-in one.
|
|
53
|
+
*/
|
|
54
|
+
function resultPages(issuer, hosted) {
|
|
55
|
+
const redirect = (res, location) => res.writeHead(302, { location, 'cache-control': 'no-store' }).end();
|
|
56
|
+
const html = (res, status, page) => res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }).end(page);
|
|
57
|
+
return {
|
|
58
|
+
signedIn(res, { agentKey, canTrade, apiHost }) {
|
|
59
|
+
if (res.writableEnded) return;
|
|
60
|
+
if (hosted) {
|
|
61
|
+
const query = new URLSearchParams({ ...(agentKey ? { agent: agentKey } : {}), access: canTrade ? 'trade' : 'read', ...(apiHost ? { api: apiHost } : {}) });
|
|
62
|
+
return redirect(res, `${issuer}/cli/signed-in?${query}`);
|
|
63
|
+
}
|
|
64
|
+
return html(res, 200, fallbackPage({
|
|
65
|
+
ok: true,
|
|
66
|
+
title: "You're signed in to the Thirdfy CLI",
|
|
67
|
+
body: 'Go back to your terminal to continue. You can close this tab.',
|
|
68
|
+
facts: [
|
|
69
|
+
...(agentKey ? [['Acts as agent', shortKey(agentKey)]] : []),
|
|
70
|
+
['Access', canTrade ? 'Read and trade' : 'Read only'],
|
|
71
|
+
...(apiHost ? [['API', apiHost]] : []),
|
|
72
|
+
],
|
|
73
|
+
}));
|
|
74
|
+
},
|
|
75
|
+
error(res, reason, status = 200) {
|
|
76
|
+
if (res.writableEnded) return;
|
|
77
|
+
if (hosted) return redirect(res, `${issuer}/cli/error?reason=${encodeURIComponent(reason)}`);
|
|
78
|
+
const [title, body] = FALLBACK_ERRORS[reason] || FALLBACK_ERRORS.failed;
|
|
79
|
+
return html(res, status, fallbackPage({ ok: false, title, body }));
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether the sign-in server has the hosted result pages (auth.thirdfy.com 0.5.1+). */
|
|
85
|
+
async function hasHostedResultPages(issuer, timeoutMs) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(`${issuer}/cli/signed-in`, { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(Math.min(timeoutMs, 5000)) });
|
|
88
|
+
return response.ok && String(response.headers.get('content-type') || '').includes('text/html');
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function openBrowser(url) {
|
|
95
|
+
const platform = process.platform;
|
|
96
|
+
const [command, args] =
|
|
97
|
+
// Windows: not `cmd /c start`, which splits the URL at every `&` of its query.
|
|
98
|
+
platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
|
|
99
|
+
try {
|
|
100
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
101
|
+
child.on('error', () => {});
|
|
102
|
+
child.unref();
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Listen on 127.0.0.1 (any free port) for one `/callback` that carries our `state`. The browser's
|
|
111
|
+
* response is held until the CLI has its tokens: `result` resolves with the code and that response.
|
|
112
|
+
*/
|
|
113
|
+
function startLoopbackListener({ state, issuer, timeoutMs, pages }) {
|
|
114
|
+
let settle;
|
|
115
|
+
const result = new Promise((resolve, reject) => {
|
|
116
|
+
settle = { resolve, reject };
|
|
117
|
+
});
|
|
118
|
+
const server = createServer((req, res) => {
|
|
119
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
120
|
+
if (url.pathname !== '/callback') {
|
|
121
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end('Not found');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const params = url.searchParams;
|
|
125
|
+
if (params.get('state') !== state) {
|
|
126
|
+
pages.error(res, 'state_mismatch', 400);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const iss = params.get('iss');
|
|
130
|
+
if (iss && iss.replace(/\/+$/, '') !== issuer) {
|
|
131
|
+
pages.error(res, 'issuer_mismatch', 400);
|
|
132
|
+
settle.reject(createCliError('OAUTH_ISSUER_MISMATCH', `The sign-in answer came from ${iss}, not ${issuer}.`, { blockedStage: 'auth' }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const error = params.get('error');
|
|
136
|
+
if (error) {
|
|
137
|
+
pages.error(res, error === 'access_denied' ? 'access_denied' : 'failed');
|
|
138
|
+
settle.reject(
|
|
139
|
+
createCliError(error === 'access_denied' ? 'OAUTH_ACCESS_DENIED' : 'OAUTH_AUTHORIZE_FAILED', params.get('error_description') || `Sign-in failed: ${error}`, {
|
|
140
|
+
blockedStage: 'auth',
|
|
141
|
+
oauthError: error,
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const code = params.get('code');
|
|
147
|
+
if (!code) {
|
|
148
|
+
pages.error(res, 'failed', 400);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
settle.resolve({ code, res });
|
|
152
|
+
});
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
settle.reject(createCliError('OAUTH_LOGIN_TIMEOUT', `No sign-in arrived within ${Math.round(timeoutMs / 1000)} seconds. Run \`thirdfy-agent login\` again.`, { blockedStage: 'auth' }));
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
timer.unref?.();
|
|
157
|
+
const ready = new Promise((resolve, reject) => {
|
|
158
|
+
server.once('error', reject);
|
|
159
|
+
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
|
160
|
+
});
|
|
161
|
+
const close = () => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
server.close();
|
|
164
|
+
server.closeAllConnections?.();
|
|
165
|
+
};
|
|
166
|
+
return { ready, result, close };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Which agent the owner picked on the consent page: the token response does not say. */
|
|
170
|
+
async function fetchGrantedAgent(ctx, accessToken) {
|
|
171
|
+
try {
|
|
172
|
+
const response = await fetch(`${ctx.apiBase}/api/v1/agent/oauth/session`, {
|
|
173
|
+
headers: { Authorization: `Bearer ${accessToken}`, accept: 'application/json', 'x-client': 'agent-cli' },
|
|
174
|
+
signal: AbortSignal.timeout(ctx.timeoutMs || 30_000),
|
|
175
|
+
});
|
|
176
|
+
const body = await response.json().catch(() => ({}));
|
|
177
|
+
if (!response.ok) return { error: body?.error || `HTTP ${response.status}` };
|
|
178
|
+
const data = body?.data || body;
|
|
179
|
+
return {
|
|
180
|
+
agentKey: data?.agentKey ? String(data.agentKey).toLowerCase() : null,
|
|
181
|
+
ownerDid: data?.ownerDid || null,
|
|
182
|
+
canTrade: data?.canTrade === true,
|
|
183
|
+
};
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { error: error?.message || 'network error' };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function commandOAuthLogin(ctx, flags, { printEnvelope, version }) {
|
|
190
|
+
const timeoutMs = ctx.timeoutMs || 30_000;
|
|
191
|
+
const resource = ctx.apiBase;
|
|
192
|
+
const issuer = resolveIssuer(resource, flags);
|
|
193
|
+
const metadata = await discoverAuthServer(issuer, resource, timeoutMs);
|
|
194
|
+
|
|
195
|
+
const config = loadProfileConfig();
|
|
196
|
+
const knownClients = config.auth?.oauthClients && typeof config.auth.oauthClients === 'object' ? config.auth.oauthClients : {};
|
|
197
|
+
let clientId = String(knownClients[issuer] || '').trim();
|
|
198
|
+
if (!clientId) {
|
|
199
|
+
clientId = await registerCliClient(metadata, { version, timeoutMs });
|
|
200
|
+
const latest = loadProfileConfig();
|
|
201
|
+
const auth = latest.auth && typeof latest.auth === 'object' ? latest.auth : {};
|
|
202
|
+
persistProfileConfig({ ...latest, auth: { ...auth, oauthClients: { ...(auth.oauthClients || {}), [issuer]: clientId } } });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const loginSeconds = Number(flags.loginTimeout) > 0 ? Number(flags.loginTimeout) : DEFAULT_LOGIN_TIMEOUT_SECONDS;
|
|
206
|
+
const state = createState();
|
|
207
|
+
const pkce = createPkcePair();
|
|
208
|
+
const pages = resultPages(issuer, await hasHostedResultPages(issuer, timeoutMs));
|
|
209
|
+
const listener = startLoopbackListener({ state, issuer, timeoutMs: loginSeconds * 1000, pages });
|
|
210
|
+
let session;
|
|
211
|
+
let grant;
|
|
212
|
+
let browserResponse;
|
|
213
|
+
try {
|
|
214
|
+
const port = await listener.ready;
|
|
215
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
216
|
+
const scope = ['mcp:read', ...(flags.trade ? ['mcp:trade'] : []), 'offline_access'].join(' ');
|
|
217
|
+
const authorizeUrl = buildAuthorizeUrl(metadata, { clientId, redirectUri, challenge: pkce.challenge, state, scope, resource });
|
|
218
|
+
const opened = flags.browser === false ? false : openBrowser(authorizeUrl);
|
|
219
|
+
// stderr: stdout stays a single JSON envelope with --json.
|
|
220
|
+
process.stderr.write(
|
|
221
|
+
`${opened ? 'Opening your browser to sign in to Thirdfy.' : 'Open this URL in a browser on this machine to sign in to Thirdfy:'}\n${opened ? 'If it did not open, use: ' : ''}${authorizeUrl}\nWaiting for the sign-in (${loginSeconds}s)...\n`,
|
|
222
|
+
);
|
|
223
|
+
const { code, res } = await listener.result;
|
|
224
|
+
browserResponse = res;
|
|
225
|
+
try {
|
|
226
|
+
const tokens = await exchangeCode(metadata, { clientId, code, redirectUri, verifier: pkce.verifier, resource }, timeoutMs);
|
|
227
|
+
const base = {
|
|
228
|
+
issuer,
|
|
229
|
+
clientId,
|
|
230
|
+
resource,
|
|
231
|
+
tokenEndpoint: metadata.token_endpoint,
|
|
232
|
+
revocationEndpoint: metadata.revocation_endpoint || null,
|
|
233
|
+
};
|
|
234
|
+
session = sessionFromTokens(base, tokens);
|
|
235
|
+
grant = await fetchGrantedAgent(ctx, session.accessToken);
|
|
236
|
+
session = {
|
|
237
|
+
...session,
|
|
238
|
+
agentKey: grant.agentKey || null,
|
|
239
|
+
ownerDid: grant.ownerDid || null,
|
|
240
|
+
canTrade: grant.error ? String(session.scope).split(' ').includes('mcp:trade') : grant.canTrade,
|
|
241
|
+
};
|
|
242
|
+
writeOAuthSession(session);
|
|
243
|
+
pages.signedIn(res, { agentKey: session.agentKey, canTrade: session.canTrade, apiHost: new URL(resource).host });
|
|
244
|
+
} catch (error) {
|
|
245
|
+
pages.error(res, 'exchange_failed');
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
// Let the browser get its last page before the listener closes its connections.
|
|
250
|
+
if (browserResponse && !browserResponse.writableFinished) {
|
|
251
|
+
await new Promise((resolve) => {
|
|
252
|
+
const timer = setTimeout(resolve, 2000);
|
|
253
|
+
browserResponse.once('finish', () => {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
resolve();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
listener.close();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const warnings = grant.error
|
|
263
|
+
? [`Signed in, but ${resource} could not say which agent this sign-in acts as (${grant.error}). The API may not accept OAuth sign-ins yet; use an agent API key or \`login email\` meanwhile.`]
|
|
264
|
+
: [];
|
|
265
|
+
printEnvelope({
|
|
266
|
+
success: true,
|
|
267
|
+
code: 'LOGIN_OK',
|
|
268
|
+
message: grant.agentKey ? `Signed in with OAuth as agent ${grant.agentKey}` : 'Signed in with OAuth',
|
|
269
|
+
data: {
|
|
270
|
+
auth: 'oauth',
|
|
271
|
+
issuer,
|
|
272
|
+
agentKey: session.agentKey,
|
|
273
|
+
ownerDid: session.ownerDid,
|
|
274
|
+
scope: session.scope,
|
|
275
|
+
canTrade: session.canTrade,
|
|
276
|
+
expiresAt: session.expiresAt,
|
|
277
|
+
configPath: getProfileConfigPath(),
|
|
278
|
+
note: session.canTrade
|
|
279
|
+
? `This sign-in acts as one agent on ${resource}, with read and trade access. Revoke it any time at ${issuer}/connections or with \`thirdfy-agent logout\`.`
|
|
280
|
+
: `This sign-in acts as one agent on ${resource}, read-only. For writes, run \`thirdfy-agent login --trade\`. Revoke it at ${issuer}/connections or with \`thirdfy-agent logout\`.`,
|
|
281
|
+
},
|
|
282
|
+
meta: { apiBase: ctx.apiBase, ...(warnings.length ? { warnings } : {}) },
|
|
283
|
+
});
|
|
284
|
+
}
|