rakomi 0.1.1 → 0.2.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/README.md +13 -2
- package/dist/ci-oidc-token.js +51 -0
- package/dist/commands/connect.js +24 -10
- package/dist/commands/login.js +82 -4
- package/dist/commands/mcp.js +45 -0
- package/dist/commands/tenants.js +8 -1
- package/dist/commands/use.js +10 -0
- package/dist/commands/whoami.js +17 -2
- package/dist/connect-client.js +4 -2
- package/dist/dpop.js +42 -0
- package/dist/env.js +32 -0
- package/dist/http.js +35 -1
- package/dist/index.js +81 -8
- package/dist/install-key.js +94 -0
- package/dist/mcp-tools-ci.js +44 -0
- package/dist/oauth-authcode.js +9 -0
- package/dist/oidc-federation-login.js +23 -0
- package/dist/pkce.js +1 -1
- package/dist/session.js +68 -8
- package/dist/tenant-config.js +50 -0
- package/dist/tenants-client.js +4 -2
- package/dist/usage.js +7 -1
- package/dist/userinfo-client.js +2 -1
- package/package.json +1 -1
- package/sbom.cdx.json +3 -3
package/README.md
CHANGED
|
@@ -9,7 +9,15 @@ npx rakomi login
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Signs you in with your browser (loopback OAuth + PKCE). Add `--no-browser` on a headless/SSH
|
|
12
|
-
session to get a short code to enter on another device instead.
|
|
12
|
+
session to get a short code to enter on another device instead. If you don't already have a
|
|
13
|
+
`accounts.rakomi.com` session or an invitation link, pass `--tenant-id <uuid>` (or set
|
|
14
|
+
`RAKOMI_PLATFORM_TENANT_ID`) so sign-in knows which tenant to authenticate against — ask your
|
|
15
|
+
Rakomi administrator for the right value.
|
|
16
|
+
|
|
17
|
+
`rakomi whoami` shows the tenant you signed in against ("Home tenant") and the tenant your other
|
|
18
|
+
commands should treat as active ("Active tenant"). `rakomi use <tenant-id>` remembers a tenant id
|
|
19
|
+
locally for that; `--tenant <tenant-id>` overrides it for one command. Neither is a verified
|
|
20
|
+
grant — the tenant `whoami` shows as your Home tenant is the only one the server has confirmed.
|
|
13
21
|
|
|
14
22
|
## Connect an AI agent
|
|
15
23
|
|
|
@@ -45,7 +53,8 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
|
|
|
45
53
|
| --- | --- |
|
|
46
54
|
| `login` | sign in (browser by default; `--no-browser` for a device code) |
|
|
47
55
|
| `logout` | clear the local session |
|
|
48
|
-
| `whoami` | show the signed-in account |
|
|
56
|
+
| `whoami` | show the signed-in account, home tenant, and active tenant |
|
|
57
|
+
| `use <tenant-id>` | remember a tenant id locally for `whoami`/future commands |
|
|
49
58
|
| `connect` | connect Claude Code / Claude Desktop to your tenant (read access) |
|
|
50
59
|
| `tenants create <name>` | create a tenant (`--owner me\|<email>`, `--slug <slug>`) |
|
|
51
60
|
| `tenants list` | list tenants you provisioned |
|
|
@@ -59,6 +68,8 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
|
|
|
59
68
|
| `--dry-run` | print what would happen, make no writes or mutating calls |
|
|
60
69
|
| `--no-browser` | use the device-code flow instead of opening a browser |
|
|
61
70
|
| `--no-keychain` | store the session in a `0600` file instead of the OS keychain |
|
|
71
|
+
| `--tenant-id <uuid>` | (`login`) the tenant to sign in against, if `RAKOMI_PLATFORM_TENANT_ID` isn't set for you |
|
|
72
|
+
| `--tenant <tenant-id>` | override the active tenant for a single command (`whoami` today) |
|
|
62
73
|
| `--client <name>` | `claude-code` or `claude-desktop` — required when more than one applies |
|
|
63
74
|
| `--undo` | restore the `.mcp.json` `connect` last backed up |
|
|
64
75
|
| `--cimd-url <url>` | (`connect`) the connecting client's own CIMD document URL — confirms/re-checks the connection |
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CliError, EXIT } from './errors.js';
|
|
2
|
+
import { request } from './http.js';
|
|
3
|
+
export const NO_OIDC_SOURCE_MESSAGE = 'rakomi login --ci found no OIDC identity token. Set RAKOMI_OIDC_TOKEN, pass ' +
|
|
4
|
+
'--oidc-token-file <path>, or run on a CI platform this command recognizes automatically ' +
|
|
5
|
+
'(GitHub Actions with `permissions: id-token: write`, or GitLab CI with CI_JOB_JWT_V2).';
|
|
6
|
+
export async function resolveCiOidcToken(deps, opts) {
|
|
7
|
+
if (opts.oidcTokenFile) {
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = opts.readTextFile(opts.oidcTokenFile);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new CliError(`Could not read --oidc-token-file ${JSON.stringify(opts.oidcTokenFile)}.`, EXIT.FAIL);
|
|
14
|
+
}
|
|
15
|
+
const token = raw.trim();
|
|
16
|
+
if (token.length === 0) {
|
|
17
|
+
throw new CliError(`--oidc-token-file ${JSON.stringify(opts.oidcTokenFile)} was empty.`, EXIT.FAIL);
|
|
18
|
+
}
|
|
19
|
+
return token;
|
|
20
|
+
}
|
|
21
|
+
if (opts.env.RAKOMI_OIDC_TOKEN) {
|
|
22
|
+
return opts.env.RAKOMI_OIDC_TOKEN;
|
|
23
|
+
}
|
|
24
|
+
if (opts.env.ACTIONS_ID_TOKEN_REQUEST_URL && opts.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) {
|
|
25
|
+
return requestGitHubActionsIdToken(deps, opts.env.ACTIONS_ID_TOKEN_REQUEST_URL, opts.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, opts.audience);
|
|
26
|
+
}
|
|
27
|
+
const gitlabToken = opts.env.CI_JOB_JWT_V2 || opts.env.CI_JOB_JWT;
|
|
28
|
+
if (gitlabToken) {
|
|
29
|
+
return gitlabToken;
|
|
30
|
+
}
|
|
31
|
+
throw new CliError(NO_OIDC_SOURCE_MESSAGE, EXIT.FAIL);
|
|
32
|
+
}
|
|
33
|
+
async function requestGitHubActionsIdToken(deps, requestUrl, requestToken, audience) {
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(requestUrl);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new CliError('ACTIONS_ID_TOKEN_REQUEST_URL is not a valid URL.', EXIT.FAIL);
|
|
40
|
+
}
|
|
41
|
+
url.searchParams.set('audience', audience);
|
|
42
|
+
const result = await request(deps, {
|
|
43
|
+
method: 'GET',
|
|
44
|
+
url: url.toString(),
|
|
45
|
+
headers: { authorization: `Bearer ${requestToken}` },
|
|
46
|
+
});
|
|
47
|
+
if (result.status !== 200 || typeof result.body.value !== 'string' || result.body.value.length === 0) {
|
|
48
|
+
throw new CliError('Could not obtain a GitHub Actions OIDC token (check `permissions: id-token: write` on this job).', EXIT.FAIL);
|
|
49
|
+
}
|
|
50
|
+
return result.body.value;
|
|
51
|
+
}
|
package/dist/commands/connect.js
CHANGED
|
@@ -2,6 +2,7 @@ import { AmbiguousClientError, clientDisplayName, resolveTargetClients } from '.
|
|
|
2
2
|
import { pollCimdMaterialization, requestWriteElevation } from '../connect-client.js';
|
|
3
3
|
import { DEFAULT_MCP_URL } from '../env.js';
|
|
4
4
|
import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
|
|
5
|
+
import { resolveDpopKey } from '../install-key.js';
|
|
5
6
|
import { mcpConfigPath, undoMcpConfigWrite, writeRakomiMcpServerEntry } from '../mcp-config.js';
|
|
6
7
|
const CLAUDE_DESKTOP_INSTRUCTIONS = [
|
|
7
8
|
'Claude Desktop connects to remote MCP servers through Connectors, configured from your Claude',
|
|
@@ -24,7 +25,7 @@ export async function runConnect(deps) {
|
|
|
24
25
|
if (!session)
|
|
25
26
|
throw new NotLoggedInError();
|
|
26
27
|
if (deps.status) {
|
|
27
|
-
await reportStatus(deps, session
|
|
28
|
+
await reportStatus(deps, session);
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
31
|
let targets;
|
|
@@ -41,24 +42,30 @@ export async function runConnect(deps) {
|
|
|
41
42
|
deps.stdout.write(`${clientDisplayName(client)}:\n${CLAUDE_DESKTOP_INSTRUCTIONS}\n`);
|
|
42
43
|
continue;
|
|
43
44
|
}
|
|
44
|
-
await connectClaudeCode(deps, session
|
|
45
|
+
await connectClaudeCode(deps, session);
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
|
-
async function reportStatus(deps,
|
|
48
|
+
async function reportStatus(deps, session) {
|
|
48
49
|
if (!deps.cimdUrl) {
|
|
49
50
|
throw new UsageError('`connect --status` needs `--cimd-url <url>` — the CIMD URL `connect` printed when it started waiting.');
|
|
50
51
|
}
|
|
51
|
-
const
|
|
52
|
+
const dpop = resolveDpopKey(deps.keys, session);
|
|
53
|
+
const row = await pollCimdMaterialization(deps, {
|
|
54
|
+
apiBaseUrl: deps.apiBaseUrl,
|
|
55
|
+
accessToken: session.access_token,
|
|
56
|
+
cimdUrl: deps.cimdUrl,
|
|
57
|
+
dpop,
|
|
58
|
+
});
|
|
52
59
|
if (!row) {
|
|
53
60
|
deps.stdout.write('Not connected yet — finish sign-in in your MCP client, then run this command again.\n');
|
|
54
61
|
return;
|
|
55
62
|
}
|
|
56
63
|
deps.stdout.write(`Connected — access: ${row.agent_access.level}${row.agent_access.owner_granted ? ' (owner-granted)' : ''}.\n`);
|
|
57
64
|
if (deps.write && row.agent_access.level !== 'write') {
|
|
58
|
-
await requestWrite(deps,
|
|
65
|
+
await requestWrite(deps, session, row.id);
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
|
-
async function connectClaudeCode(deps,
|
|
68
|
+
async function connectClaudeCode(deps, session) {
|
|
62
69
|
if (deps.dryRun) {
|
|
63
70
|
deps.stdout.write(`Would write ${mcpConfigPath(deps.cwd)} with mcpServers.rakomi -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
|
|
64
71
|
return;
|
|
@@ -78,7 +85,13 @@ async function connectClaudeCode(deps, accessToken) {
|
|
|
78
85
|
'check the Agents page in the dashboard.\n');
|
|
79
86
|
return;
|
|
80
87
|
}
|
|
81
|
-
const
|
|
88
|
+
const dpop = resolveDpopKey(deps.keys, session);
|
|
89
|
+
const row = await pollCimdMaterialization(deps, {
|
|
90
|
+
apiBaseUrl: deps.apiBaseUrl,
|
|
91
|
+
accessToken: session.access_token,
|
|
92
|
+
cimdUrl: deps.cimdUrl,
|
|
93
|
+
dpop,
|
|
94
|
+
});
|
|
82
95
|
if (!row) {
|
|
83
96
|
deps.stdout.write(`\nNot connected yet. Ctrl+C is safe — resume any time with:\n` +
|
|
84
97
|
` rakomi connect --status --cimd-url ${deps.cimdUrl}\n`);
|
|
@@ -89,11 +102,12 @@ async function connectClaudeCode(deps, accessToken) {
|
|
|
89
102
|
if (deps.ci) {
|
|
90
103
|
throw new InteractiveRequiredError('`connect --write` cannot run under --ci — write-access needs a human owner decision.');
|
|
91
104
|
}
|
|
92
|
-
await requestWrite(deps,
|
|
105
|
+
await requestWrite(deps, session, row.id);
|
|
93
106
|
}
|
|
94
107
|
}
|
|
95
|
-
async function requestWrite(deps,
|
|
96
|
-
|
|
108
|
+
async function requestWrite(deps, session, oauthClientId) {
|
|
109
|
+
const dpop = resolveDpopKey(deps.keys, session);
|
|
110
|
+
await requestWriteElevation(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken: session.access_token, oauthClientId, dpop });
|
|
97
111
|
deps.stdout.write("Write-access request sent to your tenant's owner(s) — if that's you, you'll approve it yourself " +
|
|
98
112
|
'from the dashboard Agents page. Destructive actions will still ask a human every time.\n');
|
|
99
113
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveCiOidcToken } from '../ci-oidc-token.js';
|
|
2
|
+
import { generateEphemeralDpopKey } from '../dpop.js';
|
|
3
|
+
import { accountsBaseUrl, apiBaseUrl, CI_FEDERATION_SESSION_CLIENT_ID, clientId as resolveClientId, isCimdClientId, isCimdPlatformSupported, isValidTenantId, platformTenantId, } from '../env.js';
|
|
2
4
|
import { CliError, EXIT } from '../errors.js';
|
|
5
|
+
import { bindInstallKey, getOrCreateInstallKey, peekInstallKey, persistInstallKey, signClientAssertion, } from '../install-key.js';
|
|
3
6
|
import { buildAuthorizeUrl, exchangeAuthorizationCode } from '../oauth-authcode.js';
|
|
4
7
|
import { loginWithDeviceGrant } from '../oauth-device.js';
|
|
8
|
+
import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
|
|
5
9
|
import { generatePkcePair } from '../pkce.js';
|
|
6
10
|
export const LOGIN_SCOPE = 'openid profile email clients:read elevation:request';
|
|
7
11
|
const LOOPBACK_TIMEOUT_MS = 5 * 60_000;
|
|
12
|
+
function cimdUnsupportedMessage(cid) {
|
|
13
|
+
return ('This Rakomi server does not have CIMD-based sign-in enabled, so the default client_id ' +
|
|
14
|
+
`(${cid}) cannot be used to sign in right now. Ask your tenant admin to register a client in ` +
|
|
15
|
+
'the dashboard, then run `rakomi login --client <your-client-id>` (or set RAKOMI_CLIENT_ID) ' +
|
|
16
|
+
'instead.');
|
|
17
|
+
}
|
|
18
|
+
function platformTenantMissingMessage() {
|
|
19
|
+
return ('rakomi login needs to know which tenant to authenticate your account against. Set ' +
|
|
20
|
+
'RAKOMI_PLATFORM_TENANT_ID to a real tenant UUID, or pass --tenant-id <uuid>. If neither is ' +
|
|
21
|
+
'set, this Rakomi environment likely does not have a platform tenant provisioned yet — ask ' +
|
|
22
|
+
'your operator.');
|
|
23
|
+
}
|
|
24
|
+
function explicitTenantIdMalformedMessage(value) {
|
|
25
|
+
return (`--tenant-id "${value}" is not a valid tenant id — expected a well-formed UUID (not the nil ` +
|
|
26
|
+
'or max UUID). Pass the real tenant id, or omit --tenant-id to fall back to RAKOMI_PLATFORM_TENANT_ID.');
|
|
27
|
+
}
|
|
28
|
+
function resolvePlatformTenantId(env, explicit) {
|
|
29
|
+
if (explicit !== undefined) {
|
|
30
|
+
if (!isValidTenantId(explicit)) {
|
|
31
|
+
throw new CliError(explicitTenantIdMalformedMessage(explicit), EXIT.FAIL);
|
|
32
|
+
}
|
|
33
|
+
return explicit;
|
|
34
|
+
}
|
|
35
|
+
return platformTenantId(env);
|
|
36
|
+
}
|
|
8
37
|
export async function runLogin(deps) {
|
|
9
38
|
const apiUrl = apiBaseUrl(deps.env);
|
|
10
39
|
const cid = resolveClientId(deps.env);
|
|
@@ -13,21 +42,42 @@ export async function runLogin(deps) {
|
|
|
13
42
|
"default CIMD client_id does not support it. Pass --client <your-client-id> (create one " +
|
|
14
43
|
'in the dashboard) for headless/SSH sign-in, or omit --no-browser to sign in via loopback.', EXIT.FAIL);
|
|
15
44
|
}
|
|
45
|
+
if (!deps.noBrowser && isCimdClientId(cid)) {
|
|
46
|
+
const supported = await isCimdPlatformSupported(deps, apiUrl);
|
|
47
|
+
if (supported === false) {
|
|
48
|
+
throw new CliError(cimdUnsupportedMessage(cid), EXIT.FAIL);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const installKey = isCimdClientId(cid) ? getOrCreateInstallKey(deps.keys, cid) : undefined;
|
|
52
|
+
let tenantId;
|
|
53
|
+
if (!deps.noBrowser && isCimdClientId(cid)) {
|
|
54
|
+
tenantId = resolvePlatformTenantId(deps.env, deps.explicitTenantId);
|
|
55
|
+
if (!tenantId) {
|
|
56
|
+
throw new CliError(platformTenantMissingMessage(), EXIT.FAIL);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
16
59
|
const token = deps.noBrowser
|
|
17
60
|
? await runDeviceGrantLogin(deps, apiUrl, cid)
|
|
18
|
-
: await runLoopbackLogin(deps, apiUrl, cid);
|
|
61
|
+
: await runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId);
|
|
19
62
|
const session = {
|
|
20
63
|
access_token: token.access_token,
|
|
21
64
|
refresh_token: token.refresh_token,
|
|
22
|
-
token_type: 'Bearer',
|
|
65
|
+
token_type: token.token_type === 'DPoP' ? 'DPoP' : 'Bearer',
|
|
23
66
|
expires_at: deps.now() + token.expires_in * 1000,
|
|
24
67
|
api_base_url: apiUrl,
|
|
25
68
|
client_id: cid,
|
|
69
|
+
...(tenantId ? { home_tenant_id: tenantId } : {}),
|
|
26
70
|
};
|
|
71
|
+
if (token.token_type !== 'Bearer' && token.token_type !== 'DPoP') {
|
|
72
|
+
deps.stdout.write(`(debug) unexpected token_type "${token.token_type}" from the server — treating this session as Bearer.\n`);
|
|
73
|
+
}
|
|
27
74
|
deps.session.write(session);
|
|
28
75
|
deps.stdout.write('Logged in to Rakomi.\n');
|
|
76
|
+
if (installKey) {
|
|
77
|
+
await bindInstallKey(deps, deps.keys, { apiBaseUrl: apiUrl, clientId: cid, accessToken: token.access_token, dpopBound: session.token_type === 'DPoP' }, installKey);
|
|
78
|
+
}
|
|
29
79
|
}
|
|
30
|
-
async function runLoopbackLogin(deps, apiUrl, cid) {
|
|
80
|
+
async function runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId) {
|
|
31
81
|
const listener = await deps.startLoopback();
|
|
32
82
|
try {
|
|
33
83
|
const pkce = generatePkcePair();
|
|
@@ -37,6 +87,7 @@ async function runLoopbackLogin(deps, apiUrl, cid) {
|
|
|
37
87
|
redirectUri: listener.redirectUri,
|
|
38
88
|
pkce,
|
|
39
89
|
scope: LOGIN_SCOPE,
|
|
90
|
+
tenantId,
|
|
40
91
|
});
|
|
41
92
|
deps.stdout.write(`Opening your browser to sign in:\n ${authorizeUrl}\n`);
|
|
42
93
|
await deps.openBrowser(authorizeUrl);
|
|
@@ -53,6 +104,10 @@ async function runLoopbackLogin(deps, apiUrl, cid) {
|
|
|
53
104
|
redirectUri: listener.redirectUri,
|
|
54
105
|
code: callback.code,
|
|
55
106
|
codeVerifier: pkce.codeVerifier,
|
|
107
|
+
clientAssertion: installKey?.confirmedBound
|
|
108
|
+
? signClientAssertion(installKey, { clientId: cid, audience: `${apiUrl}/oauth/token` })
|
|
109
|
+
: undefined,
|
|
110
|
+
dpopKey: installKey,
|
|
56
111
|
});
|
|
57
112
|
}
|
|
58
113
|
finally {
|
|
@@ -68,3 +123,26 @@ async function runDeviceGrantLogin(deps, apiUrl, cid) {
|
|
|
68
123
|
},
|
|
69
124
|
}, { apiBaseUrl: apiUrl, clientId: cid, scope: LOGIN_SCOPE });
|
|
70
125
|
}
|
|
126
|
+
export async function runLoginCi(deps) {
|
|
127
|
+
const apiUrl = apiBaseUrl(deps.env);
|
|
128
|
+
const subjectToken = await resolveCiOidcToken(deps, {
|
|
129
|
+
env: deps.ciEnv,
|
|
130
|
+
oidcTokenFile: deps.oidcTokenFile,
|
|
131
|
+
readTextFile: deps.readTextFile,
|
|
132
|
+
audience: apiUrl,
|
|
133
|
+
});
|
|
134
|
+
const existingKey = peekInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
|
|
135
|
+
const dpopKey = existingKey ?? { clientId: CI_FEDERATION_SESSION_CLIENT_ID, ...generateEphemeralDpopKey(), confirmedBound: false };
|
|
136
|
+
const token = await exchangeOidcSubjectToken(deps, { apiBaseUrl: apiUrl, subjectToken, resource: apiUrl, dpopKey });
|
|
137
|
+
if (!existingKey)
|
|
138
|
+
persistInstallKey(deps.keys, dpopKey);
|
|
139
|
+
const session = {
|
|
140
|
+
access_token: token.access_token,
|
|
141
|
+
token_type: token.token_type,
|
|
142
|
+
expires_at: deps.now() + token.expires_in * 1000,
|
|
143
|
+
api_base_url: apiUrl,
|
|
144
|
+
client_id: CI_FEDERATION_SESSION_CLIENT_ID,
|
|
145
|
+
};
|
|
146
|
+
deps.session.write(session);
|
|
147
|
+
deps.stdout.write('Logged in to Rakomi (CI workload identity).\n');
|
|
148
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { resolveCiOidcToken } from '../ci-oidc-token.js';
|
|
2
|
+
import { generateEphemeralDpopKey } from '../dpop.js';
|
|
3
|
+
import { apiBaseUrl, CI_FEDERATION_SESSION_CLIENT_ID, DEFAULT_MCP_URL } from '../env.js';
|
|
4
|
+
import { getOrCreateInstallKey, peekInstallKey, persistInstallKey } from '../install-key.js';
|
|
5
|
+
import { listMcpToolsCi, MCP_RESOURCE } from '../mcp-tools-ci.js';
|
|
6
|
+
import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
|
|
7
|
+
function hintLine(t) {
|
|
8
|
+
const hints = [];
|
|
9
|
+
if (t.annotations?.readOnlyHint)
|
|
10
|
+
hints.push('read-only');
|
|
11
|
+
if (t.annotations?.idempotentHint)
|
|
12
|
+
hints.push('idempotent');
|
|
13
|
+
return hints.length > 0 ? ` (${hints.join(', ')})` : '';
|
|
14
|
+
}
|
|
15
|
+
export async function runMcpToolsCi(deps) {
|
|
16
|
+
const apiUrl = apiBaseUrl(deps.env);
|
|
17
|
+
const mcpUrl = deps.env.RAKOMI_API_URL ? `${apiUrl}/mcp` : DEFAULT_MCP_URL;
|
|
18
|
+
const subjectToken = await resolveCiOidcToken(deps, {
|
|
19
|
+
env: deps.ciEnv,
|
|
20
|
+
oidcTokenFile: deps.oidcTokenFile,
|
|
21
|
+
readTextFile: deps.readTextFile,
|
|
22
|
+
audience: MCP_RESOURCE,
|
|
23
|
+
});
|
|
24
|
+
const existingKey = peekInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
|
|
25
|
+
const dpopKey = existingKey ?? { clientId: CI_FEDERATION_SESSION_CLIENT_ID, ...generateEphemeralDpopKey(), confirmedBound: false };
|
|
26
|
+
const token = await exchangeOidcSubjectToken(deps, {
|
|
27
|
+
apiBaseUrl: apiUrl,
|
|
28
|
+
subjectToken,
|
|
29
|
+
resource: MCP_RESOURCE,
|
|
30
|
+
dpopKey,
|
|
31
|
+
});
|
|
32
|
+
if (!existingKey)
|
|
33
|
+
persistInstallKey(deps.keys, dpopKey);
|
|
34
|
+
const resolvedKey = getOrCreateInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
|
|
35
|
+
const tools = await listMcpToolsCi(deps, { mcpUrl, accessToken: token.access_token, dpopKey: resolvedKey });
|
|
36
|
+
if (deps.json) {
|
|
37
|
+
deps.stdout.write(JSON.stringify({ tools }) + '\n');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (tools.length === 0) {
|
|
41
|
+
deps.stdout.write('No MCP tools visible to this CI identity.\n');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
deps.stdout.write(tools.map((t) => `${t.name}${hintLine(t)} — ${t.description}`).join('\n') + '\n');
|
|
45
|
+
}
|
package/dist/commands/tenants.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
|
|
2
|
+
import { resolveDpopKey } from '../install-key.js';
|
|
2
3
|
import { createTenant, listTenants } from '../tenants-client.js';
|
|
3
4
|
export async function runTenantsCreate(deps, args) {
|
|
4
5
|
const session = deps.session.read();
|
|
@@ -23,6 +24,7 @@ export async function runTenantsCreate(deps, args) {
|
|
|
23
24
|
name: args.name,
|
|
24
25
|
slug: args.slug,
|
|
25
26
|
ownerEmail: ownerIsSelf ? undefined : args.owner,
|
|
27
|
+
dpop: resolveDpopKey(deps.keys, session),
|
|
26
28
|
});
|
|
27
29
|
if (deps.json) {
|
|
28
30
|
deps.stdout.write(JSON.stringify(result) + '\n');
|
|
@@ -41,7 +43,12 @@ export async function runTenantsList(deps) {
|
|
|
41
43
|
const session = deps.session.read();
|
|
42
44
|
if (!session)
|
|
43
45
|
throw new NotLoggedInError();
|
|
44
|
-
const result = await listTenants(deps, {
|
|
46
|
+
const result = await listTenants(deps, {
|
|
47
|
+
apiBaseUrl: session.api_base_url,
|
|
48
|
+
accessToken: session.access_token,
|
|
49
|
+
parent: 'me',
|
|
50
|
+
dpop: resolveDpopKey(deps.keys, session),
|
|
51
|
+
});
|
|
45
52
|
if (deps.json) {
|
|
46
53
|
deps.stdout.write(JSON.stringify(result) + '\n');
|
|
47
54
|
return;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isValidTenantId } from '../env.js';
|
|
2
|
+
import { UsageError } from '../errors.js';
|
|
3
|
+
export function runUse(deps, args) {
|
|
4
|
+
if (!isValidTenantId(args.tenantId)) {
|
|
5
|
+
throw new UsageError(`"${args.tenantId}" is not a valid tenant id — expected a UUID (e.g. the value \`rakomi login\` ` +
|
|
6
|
+
'printed as "Home tenant", or the id shown in the dashboard).');
|
|
7
|
+
}
|
|
8
|
+
deps.tenantConfig.write(args.tenantId);
|
|
9
|
+
deps.stdout.write(`Active tenant set to ${args.tenantId}.\n`);
|
|
10
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
import { NotLoggedInError } from '../errors.js';
|
|
2
|
+
import { resolveDpopKey } from '../install-key.js';
|
|
2
3
|
import { fetchUserInfo } from '../userinfo-client.js';
|
|
3
4
|
export async function runWhoami(deps) {
|
|
4
5
|
const session = deps.session.read();
|
|
5
6
|
if (!session)
|
|
6
7
|
throw new NotLoggedInError();
|
|
7
|
-
const
|
|
8
|
+
const dpop = resolveDpopKey(deps.keys, session);
|
|
9
|
+
const info = await fetchUserInfo(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token, dpop });
|
|
10
|
+
const activeTenantId = deps.explicitTenant ?? deps.tenantConfig.read() ?? undefined;
|
|
8
11
|
if (deps.json) {
|
|
9
|
-
deps.stdout.write(JSON.stringify({
|
|
12
|
+
deps.stdout.write(JSON.stringify({
|
|
13
|
+
sub: info.sub,
|
|
14
|
+
email: info.email,
|
|
15
|
+
org_id: info.org_id,
|
|
16
|
+
org_role: info.org_role,
|
|
17
|
+
session_store: deps.session.describePath(),
|
|
18
|
+
token_type: session.token_type,
|
|
19
|
+
home_tenant_id: session.home_tenant_id,
|
|
20
|
+
active_tenant_id: activeTenantId,
|
|
21
|
+
}) + '\n');
|
|
10
22
|
return;
|
|
11
23
|
}
|
|
12
24
|
const lines = [`Signed in as: ${info.email ?? info.sub}`];
|
|
13
25
|
if (info.org_id)
|
|
14
26
|
lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
|
|
15
27
|
lines.push(`API: ${session.api_base_url}`);
|
|
28
|
+
lines.push(`Home tenant: ${session.home_tenant_id ?? 'unknown (this session did not authenticate via the CIMD-default flow)'}`);
|
|
29
|
+
lines.push(`Active tenant: ${activeTenantId ?? 'none set (run `rakomi use <tenant-id>`)'}`);
|
|
16
30
|
lines.push(`Session stored in: ${deps.session.describePath()}`);
|
|
31
|
+
lines.push(`Token type: ${session.token_type}`);
|
|
17
32
|
deps.stdout.write(lines.join('\n') + '\n');
|
|
18
33
|
}
|
package/dist/connect-client.js
CHANGED
|
@@ -6,7 +6,8 @@ export async function pollCimdMaterialization(deps, opts) {
|
|
|
6
6
|
const result = await request(deps, {
|
|
7
7
|
method: 'GET',
|
|
8
8
|
url: url.toString(),
|
|
9
|
-
headers: { authorization: `Bearer ${opts.accessToken}` },
|
|
9
|
+
headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
10
|
+
dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
|
|
10
11
|
});
|
|
11
12
|
if (result.status === 401) {
|
|
12
13
|
throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
|
|
@@ -20,8 +21,9 @@ export async function requestWriteElevation(deps, opts) {
|
|
|
20
21
|
const result = await request(deps, {
|
|
21
22
|
method: 'POST',
|
|
22
23
|
url: `${opts.apiBaseUrl}/v1/oauth-clients/${encodeURIComponent(opts.oauthClientId)}/elevation-requests`,
|
|
23
|
-
headers: { authorization: `Bearer ${opts.accessToken}` },
|
|
24
|
+
headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
24
25
|
body: { scopes: ['mcp:tools:write'] },
|
|
26
|
+
dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
|
|
25
27
|
});
|
|
26
28
|
if (result.status === 401) {
|
|
27
29
|
throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
|
package/dist/dpop.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign as cryptoSign } from 'node:crypto';
|
|
2
|
+
export function generateEphemeralDpopKey() {
|
|
3
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
4
|
+
const publicJwk = publicKey.export({ format: 'jwk' });
|
|
5
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
6
|
+
return { privateKeyPem, publicJwk };
|
|
7
|
+
}
|
|
8
|
+
function base64url(buf) {
|
|
9
|
+
return buf.toString('base64url');
|
|
10
|
+
}
|
|
11
|
+
export function canonicalizeHtu(input) {
|
|
12
|
+
const url = new URL(input);
|
|
13
|
+
const protocol = url.protocol.toLowerCase();
|
|
14
|
+
const isDefaultPort = (protocol === 'https:' && url.port === '443') || (protocol === 'http:' && url.port === '80');
|
|
15
|
+
const host = (isDefaultPort ? url.hostname : url.host).toLowerCase();
|
|
16
|
+
const lowerHexPath = url.pathname.replace(/%([0-9A-Fa-f]{2})/g, (_m, h) => `%${h.toLowerCase()}`);
|
|
17
|
+
const collapsed = lowerHexPath.replace(/\/{2,}/g, '/');
|
|
18
|
+
const path = collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed;
|
|
19
|
+
return `${protocol}//${host}${path}`;
|
|
20
|
+
}
|
|
21
|
+
export function buildDpopProof(key, claims, opts) {
|
|
22
|
+
const privateKey = createPrivateKey(key.privateKeyPem);
|
|
23
|
+
const now = opts?.nowSec ?? Math.floor(Date.now() / 1000);
|
|
24
|
+
const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk };
|
|
25
|
+
const payload = {
|
|
26
|
+
jti: randomUUID(),
|
|
27
|
+
htm: claims.htm,
|
|
28
|
+
htu: claims.htu,
|
|
29
|
+
iat: now,
|
|
30
|
+
};
|
|
31
|
+
if (claims.accessToken !== undefined) {
|
|
32
|
+
payload.ath = createHash('sha256').update(claims.accessToken, 'utf8').digest('base64url');
|
|
33
|
+
}
|
|
34
|
+
if (claims.nonce !== undefined) {
|
|
35
|
+
payload.nonce = claims.nonce;
|
|
36
|
+
}
|
|
37
|
+
const h64 = base64url(Buffer.from(JSON.stringify(header)));
|
|
38
|
+
const p64 = base64url(Buffer.from(JSON.stringify(payload)));
|
|
39
|
+
const signingInput = `${h64}.${p64}`;
|
|
40
|
+
const signature = cryptoSign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' });
|
|
41
|
+
return `${signingInput}.${base64url(signature)}`;
|
|
42
|
+
}
|
package/dist/env.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { request } from './http.js';
|
|
1
2
|
export const DEFAULT_API_BASE_URL = 'https://api.rakomi.com';
|
|
2
3
|
export const DEFAULT_ACCOUNTS_BASE_URL = 'https://accounts.rakomi.com';
|
|
3
4
|
export const DEFAULT_MCP_URL = 'https://mcp.rakomi.com/mcp';
|
|
4
5
|
export const DEFAULT_CIMD_CLIENT_ID_URL = 'https://api.rakomi.com/.well-known/rakomi-cli/client-metadata.json';
|
|
6
|
+
export const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
|
|
5
7
|
export function isCimdClientId(id) {
|
|
6
8
|
let url;
|
|
7
9
|
try {
|
|
@@ -21,3 +23,33 @@ export function accountsBaseUrl(env) {
|
|
|
21
23
|
export function clientId(env) {
|
|
22
24
|
return env.RAKOMI_CLIENT_ID || DEFAULT_CIMD_CLIENT_ID_URL;
|
|
23
25
|
}
|
|
26
|
+
const TENANT_ID_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
const NIL_TENANT_ID = '00000000-0000-0000-0000-000000000000';
|
|
28
|
+
const MAX_TENANT_ID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
|
|
29
|
+
export function isValidTenantId(id) {
|
|
30
|
+
if (id.length !== 36)
|
|
31
|
+
return false;
|
|
32
|
+
if (!TENANT_ID_UUID_PATTERN.test(id))
|
|
33
|
+
return false;
|
|
34
|
+
const lower = id.toLowerCase();
|
|
35
|
+
return lower !== NIL_TENANT_ID && lower !== MAX_TENANT_ID;
|
|
36
|
+
}
|
|
37
|
+
export function platformTenantId(env) {
|
|
38
|
+
const raw = env.RAKOMI_PLATFORM_TENANT_ID;
|
|
39
|
+
return raw && isValidTenantId(raw) ? raw : undefined;
|
|
40
|
+
}
|
|
41
|
+
const CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD = 'client_id_metadata_document_supported';
|
|
42
|
+
export async function isCimdPlatformSupported(deps, apiUrl) {
|
|
43
|
+
try {
|
|
44
|
+
const result = await request(deps, {
|
|
45
|
+
method: 'GET',
|
|
46
|
+
url: `${apiUrl}/.well-known/oauth-authorization-server`,
|
|
47
|
+
});
|
|
48
|
+
if (result.status !== 200)
|
|
49
|
+
return undefined;
|
|
50
|
+
return result.body[CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD] === true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildDpopProof, canonicalizeHtu } from './dpop.js';
|
|
1
2
|
import { CliError, EXIT } from './errors.js';
|
|
2
3
|
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
3
4
|
function isErrorEnvelope(v) {
|
|
@@ -25,7 +26,7 @@ export function errorCode(body) {
|
|
|
25
26
|
return body.code;
|
|
26
27
|
return undefined;
|
|
27
28
|
}
|
|
28
|
-
|
|
29
|
+
async function performOnce(deps, req, dpopNonce) {
|
|
29
30
|
const controller = new AbortController();
|
|
30
31
|
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
31
32
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -42,6 +43,18 @@ export async function request(deps, req) {
|
|
|
42
43
|
}
|
|
43
44
|
if (req.idempotencyKey)
|
|
44
45
|
headers['idempotency-key'] = req.idempotencyKey;
|
|
46
|
+
if (req.dpop) {
|
|
47
|
+
const proof = buildDpopProof(req.dpop.key, {
|
|
48
|
+
htm: req.method,
|
|
49
|
+
htu: canonicalizeHtu(req.url),
|
|
50
|
+
accessToken: req.dpop.accessToken,
|
|
51
|
+
nonce: dpopNonce,
|
|
52
|
+
});
|
|
53
|
+
headers['dpop'] = proof;
|
|
54
|
+
if (req.dpop.accessToken !== undefined) {
|
|
55
|
+
headers['authorization'] = `DPoP ${req.dpop.accessToken}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
45
58
|
let res;
|
|
46
59
|
try {
|
|
47
60
|
res = await deps.fetchImpl(req.url, { method: req.method, headers, body, signal: controller.signal });
|
|
@@ -68,3 +81,24 @@ export async function request(deps, req) {
|
|
|
68
81
|
clearTimeout(timer);
|
|
69
82
|
}
|
|
70
83
|
}
|
|
84
|
+
function isDpopNonceChallenge(result) {
|
|
85
|
+
if (result.status !== 401)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (errorCode(result.body) !== 'auth/invalid_dpop_proof')
|
|
88
|
+
return undefined;
|
|
89
|
+
const nonce = result.headers.get('dpop-nonce');
|
|
90
|
+
return nonce && nonce.length > 0 ? nonce : undefined;
|
|
91
|
+
}
|
|
92
|
+
export async function request(deps, req) {
|
|
93
|
+
const first = await performOnce(deps, req);
|
|
94
|
+
if (!req.dpop)
|
|
95
|
+
return first;
|
|
96
|
+
const nonce = isDpopNonceChallenge(first);
|
|
97
|
+
if (nonce === undefined)
|
|
98
|
+
return first;
|
|
99
|
+
const retry = await performOnce(deps, req, nonce);
|
|
100
|
+
if (isDpopNonceChallenge(retry) !== undefined) {
|
|
101
|
+
throw new CliError('The Rakomi API kept demanding a fresh DPoP nonce; the request could not complete.', EXIT.FAIL);
|
|
102
|
+
}
|
|
103
|
+
return retry;
|
|
104
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -6,14 +6,17 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { systemBrowserOpener } from './browser.js';
|
|
8
8
|
import { runConnect } from './commands/connect.js';
|
|
9
|
-
import { runLogin } from './commands/login.js';
|
|
9
|
+
import { runLogin, runLoginCi } from './commands/login.js';
|
|
10
10
|
import { runLogout } from './commands/logout.js';
|
|
11
|
+
import { runMcpToolsCi } from './commands/mcp.js';
|
|
11
12
|
import { runTenantsCreate, runTenantsList } from './commands/tenants.js';
|
|
13
|
+
import { runUse } from './commands/use.js';
|
|
12
14
|
import { runWhoami } from './commands/whoami.js';
|
|
13
15
|
import { apiBaseUrl, DEFAULT_MCP_URL } from './env.js';
|
|
14
16
|
import { CliError, EXIT, UsageError } from './errors.js';
|
|
15
17
|
import { startLoopbackListener } from './loopback-server.js';
|
|
16
18
|
import { resolveStores } from './session.js';
|
|
19
|
+
import { FileTenantConfigStore } from './tenant-config.js';
|
|
17
20
|
import { helpText, usageLine } from './usage.js';
|
|
18
21
|
const GLOBAL_OPTIONS = {
|
|
19
22
|
json: { type: 'boolean' },
|
|
@@ -22,11 +25,14 @@ const GLOBAL_OPTIONS = {
|
|
|
22
25
|
'dry-run': { type: 'boolean' },
|
|
23
26
|
'no-browser': { type: 'boolean' },
|
|
24
27
|
'no-keychain': { type: 'boolean' },
|
|
28
|
+
'oidc-token-file': { type: 'string' },
|
|
25
29
|
client: { type: 'string' },
|
|
26
30
|
undo: { type: 'boolean' },
|
|
27
31
|
write: { type: 'boolean' },
|
|
28
32
|
owner: { type: 'string' },
|
|
29
33
|
slug: { type: 'string' },
|
|
34
|
+
'tenant-id': { type: 'string' },
|
|
35
|
+
tenant: { type: 'string' },
|
|
30
36
|
'cimd-url': { type: 'string' },
|
|
31
37
|
status: { type: 'boolean' },
|
|
32
38
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -74,29 +80,62 @@ async function dispatch(args, deps) {
|
|
|
74
80
|
const httpDeps = { fetchImpl: deps.fetchImpl };
|
|
75
81
|
const [command, ...rest] = positionals;
|
|
76
82
|
switch (command) {
|
|
77
|
-
case 'login':
|
|
83
|
+
case 'login': {
|
|
84
|
+
if (values.ci === true) {
|
|
85
|
+
await runLoginCi({
|
|
86
|
+
...httpDeps,
|
|
87
|
+
env: deps.env,
|
|
88
|
+
ciEnv: deps.ciEnv ?? {},
|
|
89
|
+
session: deps.session,
|
|
90
|
+
keys: deps.keys,
|
|
91
|
+
oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
|
|
92
|
+
readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
|
|
93
|
+
stdout: deps.stdout,
|
|
94
|
+
now: () => Date.now(),
|
|
95
|
+
});
|
|
96
|
+
return EXIT.OK;
|
|
97
|
+
}
|
|
78
98
|
await runLogin({
|
|
79
99
|
...httpDeps,
|
|
80
100
|
env: deps.env,
|
|
81
101
|
session: deps.session,
|
|
102
|
+
keys: deps.keys,
|
|
82
103
|
noBrowser: values['no-browser'] === true || ci,
|
|
83
104
|
openBrowser: systemBrowserOpener(),
|
|
84
105
|
startLoopback: startLoopbackListener,
|
|
85
106
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
86
107
|
stdout: deps.stdout,
|
|
87
108
|
now: () => Date.now(),
|
|
109
|
+
explicitTenantId: typeof values['tenant-id'] === 'string' ? values['tenant-id'] : undefined,
|
|
88
110
|
});
|
|
89
111
|
return EXIT.OK;
|
|
112
|
+
}
|
|
90
113
|
case 'logout':
|
|
91
114
|
runLogout({ session: deps.session, stdout: deps.stdout });
|
|
92
115
|
return EXIT.OK;
|
|
93
116
|
case 'whoami':
|
|
94
|
-
await runWhoami({
|
|
117
|
+
await runWhoami({
|
|
118
|
+
...httpDeps,
|
|
119
|
+
session: deps.session,
|
|
120
|
+
keys: deps.keys,
|
|
121
|
+
json,
|
|
122
|
+
stdout: deps.stdout,
|
|
123
|
+
tenantConfig: deps.tenantConfig,
|
|
124
|
+
explicitTenant: typeof values.tenant === 'string' ? values.tenant : undefined,
|
|
125
|
+
});
|
|
126
|
+
return EXIT.OK;
|
|
127
|
+
case 'use': {
|
|
128
|
+
const tenantId = rest[0];
|
|
129
|
+
if (!tenantId)
|
|
130
|
+
throw new UsageError('Usage: rakomi use <tenant-id>');
|
|
131
|
+
runUse({ tenantConfig: deps.tenantConfig, stdout: deps.stdout }, { tenantId });
|
|
95
132
|
return EXIT.OK;
|
|
133
|
+
}
|
|
96
134
|
case 'connect':
|
|
97
135
|
await runConnect({
|
|
98
136
|
...httpDeps,
|
|
99
137
|
session: deps.session,
|
|
138
|
+
keys: deps.keys,
|
|
100
139
|
cwd: deps.cwd,
|
|
101
140
|
apiBaseUrl: apiBaseUrl(deps.env),
|
|
102
141
|
mcpUrl: deps.env.RAKOMI_API_URL ? `${apiBaseUrl(deps.env)}/mcp` : DEFAULT_MCP_URL,
|
|
@@ -111,17 +150,37 @@ async function dispatch(args, deps) {
|
|
|
111
150
|
status: values.status === true,
|
|
112
151
|
});
|
|
113
152
|
return EXIT.OK;
|
|
153
|
+
case 'mcp': {
|
|
154
|
+
const [sub] = rest;
|
|
155
|
+
if (sub !== 'tools') {
|
|
156
|
+
throw new UsageError('Usage: rakomi mcp tools --ci');
|
|
157
|
+
}
|
|
158
|
+
if (values.ci !== true) {
|
|
159
|
+
throw new UsageError('rakomi mcp tools requires --ci (workload-identity federation; no interactive form exists yet).');
|
|
160
|
+
}
|
|
161
|
+
await runMcpToolsCi({
|
|
162
|
+
...httpDeps,
|
|
163
|
+
env: deps.env,
|
|
164
|
+
ciEnv: deps.ciEnv ?? {},
|
|
165
|
+
keys: deps.keys,
|
|
166
|
+
oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
|
|
167
|
+
readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
|
|
168
|
+
json,
|
|
169
|
+
stdout: deps.stdout,
|
|
170
|
+
});
|
|
171
|
+
return EXIT.OK;
|
|
172
|
+
}
|
|
114
173
|
case 'tenants': {
|
|
115
174
|
const [sub, ...tenantArgs] = rest;
|
|
116
175
|
if (sub === 'create') {
|
|
117
176
|
const name = tenantArgs[0];
|
|
118
177
|
if (!name)
|
|
119
178
|
throw new UsageError('Usage: rakomi tenants create <name> [--owner me|<email>] [--slug <slug>]');
|
|
120
|
-
await runTenantsCreate({ ...httpDeps, session: deps.session, json, dryRun, ci, stdout: deps.stdout }, { name, slug: typeof values.slug === 'string' ? values.slug : undefined, owner: typeof values.owner === 'string' ? values.owner : 'me' });
|
|
179
|
+
await runTenantsCreate({ ...httpDeps, session: deps.session, keys: deps.keys, json, dryRun, ci, stdout: deps.stdout }, { name, slug: typeof values.slug === 'string' ? values.slug : undefined, owner: typeof values.owner === 'string' ? values.owner : 'me' });
|
|
121
180
|
return EXIT.OK;
|
|
122
181
|
}
|
|
123
182
|
if (sub === 'list') {
|
|
124
|
-
await runTenantsList({ ...httpDeps, session: deps.session, json, stdout: deps.stdout });
|
|
183
|
+
await runTenantsList({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
|
|
125
184
|
return EXIT.OK;
|
|
126
185
|
}
|
|
127
186
|
throw new UsageError('Usage: rakomi tenants <create|list> ...');
|
|
@@ -130,6 +189,11 @@ async function dispatch(args, deps) {
|
|
|
130
189
|
throw new UsageError(`Unknown command "${command ?? ''}".`);
|
|
131
190
|
}
|
|
132
191
|
}
|
|
192
|
+
export function printFallbackDisclosureOnce(resolved, stderr) {
|
|
193
|
+
if (resolved.fallbackDisclosure) {
|
|
194
|
+
stderr.write(resolved.fallbackDisclosure + '\n');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
133
197
|
async function main() {
|
|
134
198
|
const major = Number(process.versions.node.split('.')[0]);
|
|
135
199
|
if (Number.isFinite(major) && major < 22) {
|
|
@@ -148,15 +212,21 @@ async function main() {
|
|
|
148
212
|
RAKOMI_ACCOUNTS_URL: process.env.RAKOMI_ACCOUNTS_URL,
|
|
149
213
|
RAKOMI_CLIENT_ID: process.env.RAKOMI_CLIENT_ID,
|
|
150
214
|
RAKOMI_CONFIG_DIR: process.env.RAKOMI_CONFIG_DIR,
|
|
215
|
+
RAKOMI_PLATFORM_TENANT_ID: process.env.RAKOMI_PLATFORM_TENANT_ID,
|
|
151
216
|
CI: process.env.CI,
|
|
152
217
|
NO_COLOR: process.env.NO_COLOR,
|
|
153
218
|
};
|
|
154
219
|
const noKeychainEnv = process.env.RAKOMI_NO_KEYCHAIN;
|
|
220
|
+
const ciEnv = {
|
|
221
|
+
ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
|
|
222
|
+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
|
|
223
|
+
CI_JOB_JWT_V2: process.env.CI_JOB_JWT_V2,
|
|
224
|
+
CI_JOB_JWT: process.env.CI_JOB_JWT,
|
|
225
|
+
RAKOMI_OIDC_TOKEN: process.env.RAKOMI_OIDC_TOKEN,
|
|
226
|
+
};
|
|
155
227
|
const noKeychainFlag = process.argv.slice(2).includes('--no-keychain');
|
|
156
228
|
const resolved = resolveStores(env, { noKeychain: noKeychainFlag || Boolean(env.CI) || Boolean(noKeychainEnv) });
|
|
157
|
-
|
|
158
|
-
process.stderr.write(resolved.fallbackDisclosure + '\n');
|
|
159
|
-
}
|
|
229
|
+
printFallbackDisclosureOnce(resolved, { write: (text) => void process.stderr.write(text) });
|
|
160
230
|
process.exitCode = await run(process.argv.slice(2), {
|
|
161
231
|
stdout: { write: (text) => void process.stdout.write(text) },
|
|
162
232
|
stderr: { write: (text) => void process.stderr.write(text) },
|
|
@@ -166,7 +236,10 @@ async function main() {
|
|
|
166
236
|
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
167
237
|
fetchImpl: fetch,
|
|
168
238
|
session: resolved.session,
|
|
239
|
+
keys: resolved.keys,
|
|
240
|
+
tenantConfig: new FileTenantConfigStore(env),
|
|
169
241
|
detectClaudeCode: () => detectClaudeCodeCli(),
|
|
242
|
+
ciEnv,
|
|
170
243
|
});
|
|
171
244
|
}
|
|
172
245
|
function detectClaudeCodeCli() {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createPrivateKey, generateKeyPairSync, randomUUID, sign as cryptoSign } from 'node:crypto';
|
|
2
|
+
import { CI_FEDERATION_SESSION_CLIENT_ID, isCimdClientId } from './env.js';
|
|
3
|
+
import { request } from './http.js';
|
|
4
|
+
const INSTALL_KEY_NAME = 'install-key';
|
|
5
|
+
const ASSERTION_LIFETIME_SEC = 60;
|
|
6
|
+
function isStoredInstallKey(v) {
|
|
7
|
+
if (typeof v !== 'object' || v === null)
|
|
8
|
+
return false;
|
|
9
|
+
const r = v;
|
|
10
|
+
return (typeof r.clientId === 'string' &&
|
|
11
|
+
typeof r.privateKeyPem === 'string' &&
|
|
12
|
+
typeof r.publicJwk === 'object' &&
|
|
13
|
+
r.publicJwk !== null &&
|
|
14
|
+
typeof r.confirmedBound === 'boolean');
|
|
15
|
+
}
|
|
16
|
+
function generateKeyPair(clientId) {
|
|
17
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
18
|
+
const publicJwk = publicKey.export({ format: 'jwk' });
|
|
19
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
20
|
+
return { clientId, privateKeyPem, publicJwk, confirmedBound: false };
|
|
21
|
+
}
|
|
22
|
+
export function peekInstallKey(keys, clientId) {
|
|
23
|
+
const raw = keys.get(INSTALL_KEY_NAME);
|
|
24
|
+
if (raw === null)
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (isStoredInstallKey(parsed) && parsed.clientId === clientId)
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
export function persistInstallKey(keys, key) {
|
|
36
|
+
keys.set(INSTALL_KEY_NAME, JSON.stringify(key));
|
|
37
|
+
}
|
|
38
|
+
export function getOrCreateInstallKey(keys, clientId) {
|
|
39
|
+
const existing = peekInstallKey(keys, clientId);
|
|
40
|
+
if (existing)
|
|
41
|
+
return existing;
|
|
42
|
+
const fresh = generateKeyPair(clientId);
|
|
43
|
+
persistInstallKey(keys, fresh);
|
|
44
|
+
return fresh;
|
|
45
|
+
}
|
|
46
|
+
function base64url(buf) {
|
|
47
|
+
return buf.toString('base64url');
|
|
48
|
+
}
|
|
49
|
+
export function signClientAssertion(key, opts) {
|
|
50
|
+
const privateKey = createPrivateKey(key.privateKeyPem);
|
|
51
|
+
const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
|
|
52
|
+
const header = { alg: 'ES256', typ: 'JWT' };
|
|
53
|
+
const payload = {
|
|
54
|
+
iss: opts.clientId,
|
|
55
|
+
sub: opts.clientId,
|
|
56
|
+
aud: opts.audience,
|
|
57
|
+
jti: randomUUID(),
|
|
58
|
+
iat: now,
|
|
59
|
+
exp: now + ASSERTION_LIFETIME_SEC,
|
|
60
|
+
};
|
|
61
|
+
const h64 = base64url(Buffer.from(JSON.stringify(header)));
|
|
62
|
+
const p64 = base64url(Buffer.from(JSON.stringify(payload)));
|
|
63
|
+
const signingInput = `${h64}.${p64}`;
|
|
64
|
+
const signature = cryptoSign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' });
|
|
65
|
+
return `${signingInput}.${base64url(signature)}`;
|
|
66
|
+
}
|
|
67
|
+
export async function bindInstallKey(deps, keys, opts, key) {
|
|
68
|
+
try {
|
|
69
|
+
const result = await request(deps, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
url: `${opts.apiBaseUrl}/v1/oauth-clients/install-key`,
|
|
72
|
+
headers: opts.dpopBound ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
73
|
+
body: { client_id: opts.clientId, jwk: key.publicJwk },
|
|
74
|
+
dpop: opts.dpopBound ? { key, accessToken: opts.accessToken } : undefined,
|
|
75
|
+
});
|
|
76
|
+
if (result.status !== 200)
|
|
77
|
+
return { attempted: true, bound: false };
|
|
78
|
+
const bound = result.body.bound === true;
|
|
79
|
+
if (bound !== key.confirmedBound) {
|
|
80
|
+
keys.set(INSTALL_KEY_NAME, JSON.stringify({ ...key, confirmedBound: bound }));
|
|
81
|
+
}
|
|
82
|
+
return { attempted: true, bound };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return { attempted: false, bound: false };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function resolveDpopKey(keys, session) {
|
|
89
|
+
if (session.token_type !== 'DPoP')
|
|
90
|
+
return undefined;
|
|
91
|
+
if (!isCimdClientId(session.client_id) && session.client_id !== CI_FEDERATION_SESSION_CLIENT_ID)
|
|
92
|
+
return undefined;
|
|
93
|
+
return getOrCreateInstallKey(keys, session.client_id);
|
|
94
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CliError, EXIT } from './errors.js';
|
|
2
|
+
import { describeError, request } from './http.js';
|
|
3
|
+
export const MCP_RESOURCE = 'https://mcp.rakomi.com';
|
|
4
|
+
const MCP_PROTOCOL_VERSION = '2026-07-28';
|
|
5
|
+
const MCP_PROTOCOL_VERSION_HEADER = 'MCP-Protocol-Version';
|
|
6
|
+
const MCP_METHOD_HEADER = 'Mcp-Method';
|
|
7
|
+
const MCP_META_PROTOCOL_VERSION_KEY = 'io.modelcontextprotocol/protocolVersion';
|
|
8
|
+
const MCP_META_CLIENT_CAPABILITIES_KEY = 'io.modelcontextprotocol/clientCapabilities';
|
|
9
|
+
function isJsonRpcError(body) {
|
|
10
|
+
return typeof body === 'object' && body !== null && 'error' in body;
|
|
11
|
+
}
|
|
12
|
+
export async function listMcpToolsCi(deps, opts) {
|
|
13
|
+
const message = {
|
|
14
|
+
jsonrpc: '2.0',
|
|
15
|
+
id: 1,
|
|
16
|
+
method: 'tools/list',
|
|
17
|
+
params: {
|
|
18
|
+
_meta: {
|
|
19
|
+
[MCP_META_PROTOCOL_VERSION_KEY]: MCP_PROTOCOL_VERSION,
|
|
20
|
+
[MCP_META_CLIENT_CAPABILITIES_KEY]: {},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const result = await request(deps, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
url: opts.mcpUrl,
|
|
27
|
+
headers: {
|
|
28
|
+
[MCP_PROTOCOL_VERSION_HEADER]: MCP_PROTOCOL_VERSION,
|
|
29
|
+
[MCP_METHOD_HEADER]: 'tools/list',
|
|
30
|
+
},
|
|
31
|
+
body: message,
|
|
32
|
+
dpop: { key: opts.dpopKey, accessToken: opts.accessToken },
|
|
33
|
+
});
|
|
34
|
+
if (result.status === 401) {
|
|
35
|
+
throw new CliError(`Could not list MCP tools: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
36
|
+
}
|
|
37
|
+
if (isJsonRpcError(result.body)) {
|
|
38
|
+
throw new CliError(`Could not list MCP tools: ${result.body.error.message}`, EXIT.FAIL);
|
|
39
|
+
}
|
|
40
|
+
if (result.status !== 200 || typeof result.body !== 'object' || result.body === null || !('result' in result.body)) {
|
|
41
|
+
throw new CliError(`Could not list MCP tools: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
42
|
+
}
|
|
43
|
+
return result.body.result.tools;
|
|
44
|
+
}
|
package/dist/oauth-authcode.js
CHANGED
|
@@ -9,6 +9,8 @@ export function buildAuthorizeUrl(opts) {
|
|
|
9
9
|
url.searchParams.set('state', opts.pkce.state);
|
|
10
10
|
url.searchParams.set('code_challenge', opts.pkce.codeChallenge);
|
|
11
11
|
url.searchParams.set('code_challenge_method', opts.pkce.codeChallengeMethod);
|
|
12
|
+
if (opts.tenantId)
|
|
13
|
+
url.searchParams.set('tenant_id', opts.tenantId);
|
|
12
14
|
return url.toString();
|
|
13
15
|
}
|
|
14
16
|
export async function exchangeAuthorizationCode(deps, opts) {
|
|
@@ -21,7 +23,14 @@ export async function exchangeAuthorizationCode(deps, opts) {
|
|
|
21
23
|
redirect_uri: opts.redirectUri,
|
|
22
24
|
client_id: opts.clientId,
|
|
23
25
|
code_verifier: opts.codeVerifier,
|
|
26
|
+
...(opts.clientAssertion
|
|
27
|
+
? {
|
|
28
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
29
|
+
client_assertion: opts.clientAssertion,
|
|
30
|
+
}
|
|
31
|
+
: {}),
|
|
24
32
|
},
|
|
33
|
+
dpop: opts.dpopKey ? { key: opts.dpopKey } : undefined,
|
|
25
34
|
});
|
|
26
35
|
if (result.status !== 200) {
|
|
27
36
|
throw new CliError(`Could not complete sign-in: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { buildDpopProof, canonicalizeHtu } from './dpop.js';
|
|
2
|
+
import { CliError, EXIT } from './errors.js';
|
|
3
|
+
import { describeError, request } from './http.js';
|
|
4
|
+
export const OIDC_FEDERATION_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
|
|
5
|
+
export async function exchangeOidcSubjectToken(deps, opts) {
|
|
6
|
+
const tokenUrl = `${opts.apiBaseUrl}/oauth/token`;
|
|
7
|
+
const proof = buildDpopProof(opts.dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
|
|
8
|
+
const result = await request(deps, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
url: tokenUrl,
|
|
11
|
+
headers: { dpop: proof },
|
|
12
|
+
form: {
|
|
13
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
14
|
+
subject_token: opts.subjectToken,
|
|
15
|
+
subject_token_type: OIDC_FEDERATION_SUBJECT_TOKEN_TYPE,
|
|
16
|
+
resource: opts.resource,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
if (result.status !== 200) {
|
|
20
|
+
throw new CliError(`Could not sign in from CI: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
21
|
+
}
|
|
22
|
+
return result.body;
|
|
23
|
+
}
|
package/dist/pkce.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
function base64url(buf) {
|
|
3
|
-
return buf.toString('
|
|
3
|
+
return buf.toString('base64url');
|
|
4
4
|
}
|
|
5
5
|
export function generateCodeVerifier() {
|
|
6
6
|
return base64url(randomBytes(32));
|
package/dist/session.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { CliError } from './errors.js';
|
|
5
6
|
export function configDir(env) {
|
|
6
7
|
if (env.RAKOMI_CONFIG_DIR)
|
|
7
8
|
return env.RAKOMI_CONFIG_DIR;
|
|
@@ -49,12 +50,18 @@ export class FileSessionStore {
|
|
|
49
50
|
return this.path;
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
function assertValidKeyName(name) {
|
|
54
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(name)) {
|
|
55
|
+
throw new TypeError(`invalid KeyStore name: ${JSON.stringify(name)} (expected 1-128 chars, alphanumeric/hyphen/underscore only, no path separators)`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
52
58
|
export class FileKeyStore {
|
|
53
59
|
dir;
|
|
54
60
|
constructor(dir) {
|
|
55
61
|
this.dir = dir;
|
|
56
62
|
}
|
|
57
63
|
path(name) {
|
|
64
|
+
assertValidKeyName(name);
|
|
58
65
|
return join(this.dir, `key-${name}.json`);
|
|
59
66
|
}
|
|
60
67
|
get(name) {
|
|
@@ -88,20 +95,50 @@ function isStoredSession(value) {
|
|
|
88
95
|
const v = value;
|
|
89
96
|
return (typeof v.access_token === 'string' &&
|
|
90
97
|
v.access_token.length > 0 &&
|
|
91
|
-
v.token_type === 'Bearer' &&
|
|
98
|
+
(v.token_type === 'Bearer' || v.token_type === 'DPoP') &&
|
|
92
99
|
typeof v.expires_at === 'number' &&
|
|
93
100
|
typeof v.api_base_url === 'string' &&
|
|
94
|
-
typeof v.client_id === 'string'
|
|
101
|
+
typeof v.client_id === 'string' &&
|
|
102
|
+
(v.home_tenant_id === undefined || typeof v.home_tenant_id === 'string'));
|
|
95
103
|
}
|
|
96
104
|
export function isExpired(session, now) {
|
|
97
105
|
return now >= session.expires_at;
|
|
98
106
|
}
|
|
107
|
+
export class ExecFileFailure extends Error {
|
|
108
|
+
command;
|
|
109
|
+
status;
|
|
110
|
+
stderrOutput;
|
|
111
|
+
constructor(command, status, stderrOutput) {
|
|
112
|
+
super(`${command} exited with status ${status ?? 'unknown'}${stderrOutput.length > 0 ? `: ${stderrOutput}` : ''}`);
|
|
113
|
+
this.command = command;
|
|
114
|
+
this.status = status;
|
|
115
|
+
this.stderrOutput = stderrOutput;
|
|
116
|
+
this.name = 'ExecFileFailure';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
99
119
|
function realExecFileSync(file, args, options) {
|
|
100
|
-
|
|
120
|
+
try {
|
|
121
|
+
return execFileSync(file, [...args], {
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
input: options?.input,
|
|
124
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
125
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
126
|
+
}).toString();
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
const err = e;
|
|
130
|
+
const rawStderr = err.stderr;
|
|
131
|
+
const stderrOutput = (typeof rawStderr === 'string' ? rawStderr : rawStderr?.toString('utf8')) ?? '';
|
|
132
|
+
throw new ExecFileFailure(file, err.status ?? null, stderrOutput.trim());
|
|
133
|
+
}
|
|
101
134
|
}
|
|
102
135
|
function commandExistsOnPath(binaryName, pathEnv, existsFn) {
|
|
103
136
|
return pathEnv.split(delimiter).some((dir) => dir.length > 0 && existsFn(join(dir, binaryName)));
|
|
104
137
|
}
|
|
138
|
+
function keychainReadFailure(backendLabel, cause) {
|
|
139
|
+
const detail = cause instanceof ExecFileFailure && cause.stderrOutput.length > 0 ? cause.stderrOutput : cause instanceof Error ? cause.message : String(cause);
|
|
140
|
+
return new CliError(`Could not read your session from ${backendLabel}: ${detail}`);
|
|
141
|
+
}
|
|
105
142
|
export class MacKeychainBackend {
|
|
106
143
|
execFileSyncFn;
|
|
107
144
|
platform;
|
|
@@ -127,8 +164,10 @@ export class MacKeychainBackend {
|
|
|
127
164
|
const value = out.trim();
|
|
128
165
|
return value.length > 0 ? value : null;
|
|
129
166
|
}
|
|
130
|
-
catch {
|
|
131
|
-
|
|
167
|
+
catch (e) {
|
|
168
|
+
if (e instanceof ExecFileFailure && e.status === 44)
|
|
169
|
+
return null;
|
|
170
|
+
throw keychainReadFailure('macOS Keychain', e);
|
|
132
171
|
}
|
|
133
172
|
}
|
|
134
173
|
set(service, account, value) {
|
|
@@ -165,8 +204,10 @@ export class LinuxSecretToolBackend {
|
|
|
165
204
|
const value = out.replace(/\n$/, '');
|
|
166
205
|
return value.length > 0 ? value : null;
|
|
167
206
|
}
|
|
168
|
-
catch {
|
|
169
|
-
|
|
207
|
+
catch (e) {
|
|
208
|
+
if (e instanceof ExecFileFailure && e.stderrOutput.length === 0)
|
|
209
|
+
return null;
|
|
210
|
+
throw keychainReadFailure('the Linux Secret Service', e);
|
|
170
211
|
}
|
|
171
212
|
}
|
|
172
213
|
set(service, account, value) {
|
|
@@ -227,7 +268,13 @@ export class WindowsDpapiBackend {
|
|
|
227
268
|
const valueB64 = Buffer.from(value, 'utf8').toString('base64');
|
|
228
269
|
const script = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Convert]::FromBase64String('${valueB64}'), $null, 'CurrentUser'))`;
|
|
229
270
|
const cipherB64 = this.execFileSyncFn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]).trim();
|
|
230
|
-
|
|
271
|
+
const path = this.filePath(service, account);
|
|
272
|
+
writeFileSync(path, cipherB64 + '\n', { mode: 0o600 });
|
|
273
|
+
try {
|
|
274
|
+
chmodSync(path, 0o600);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
}
|
|
231
278
|
}
|
|
232
279
|
delete(service, account) {
|
|
233
280
|
try {
|
|
@@ -286,12 +333,25 @@ export class KeychainKeyStore {
|
|
|
286
333
|
this.backend = backend;
|
|
287
334
|
}
|
|
288
335
|
get(name) {
|
|
336
|
+
try {
|
|
337
|
+
assertValidKeyName(name);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
289
342
|
return this.backend.get(KEY_SERVICE, name);
|
|
290
343
|
}
|
|
291
344
|
set(name, value) {
|
|
345
|
+
assertValidKeyName(name);
|
|
292
346
|
this.backend.set(KEY_SERVICE, name, value);
|
|
293
347
|
}
|
|
294
348
|
clear(name) {
|
|
349
|
+
try {
|
|
350
|
+
assertValidKeyName(name);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
295
355
|
this.backend.delete(KEY_SERVICE, name);
|
|
296
356
|
}
|
|
297
357
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { configDir } from './session.js';
|
|
4
|
+
function isStoredTenantConfig(value) {
|
|
5
|
+
if (typeof value !== 'object' || value === null)
|
|
6
|
+
return false;
|
|
7
|
+
const v = value;
|
|
8
|
+
return typeof v.active_tenant_id === 'string' && v.active_tenant_id.length > 0;
|
|
9
|
+
}
|
|
10
|
+
function tenantConfigFilePath(env) {
|
|
11
|
+
return join(configDir(env), 'tenant.json');
|
|
12
|
+
}
|
|
13
|
+
export class FileTenantConfigStore {
|
|
14
|
+
path;
|
|
15
|
+
dir;
|
|
16
|
+
constructor(env) {
|
|
17
|
+
this.dir = configDir(env);
|
|
18
|
+
this.path = tenantConfigFilePath(env);
|
|
19
|
+
}
|
|
20
|
+
read() {
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(this.path, 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
return isStoredTenantConfig(parsed) ? parsed.active_tenant_id : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
write(tenantId) {
|
|
31
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
const config = { active_tenant_id: tenantId };
|
|
33
|
+
writeFileSync(this.path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
34
|
+
try {
|
|
35
|
+
chmodSync(this.path, 0o600);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
clear() {
|
|
41
|
+
try {
|
|
42
|
+
rmSync(this.path, { force: true });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
describePath() {
|
|
48
|
+
return this.path;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/tenants-client.js
CHANGED
|
@@ -16,9 +16,10 @@ export async function createTenant(deps, opts) {
|
|
|
16
16
|
const result = await request(deps, {
|
|
17
17
|
method: 'POST',
|
|
18
18
|
url: `${opts.apiBaseUrl}/v1/tenants`,
|
|
19
|
-
headers: { authorization: `Bearer ${opts.accessToken}` },
|
|
19
|
+
headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
20
20
|
body: { name: opts.name, ...(opts.slug ? { slug: opts.slug } : {}), ...(opts.ownerEmail ? { owner_email: opts.ownerEmail } : {}) },
|
|
21
21
|
idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey(),
|
|
22
|
+
dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
|
|
22
23
|
});
|
|
23
24
|
if (result.status === 401) {
|
|
24
25
|
throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
|
|
@@ -46,7 +47,8 @@ export async function listTenants(deps, opts) {
|
|
|
46
47
|
const result = await request(deps, {
|
|
47
48
|
method: 'GET',
|
|
48
49
|
url: url.toString(),
|
|
49
|
-
headers: { authorization: `Bearer ${opts.accessToken}` },
|
|
50
|
+
headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
51
|
+
dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
|
|
50
52
|
});
|
|
51
53
|
if (result.status === 401) {
|
|
52
54
|
throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
|
package/dist/usage.js
CHANGED
|
@@ -7,8 +7,12 @@ export function helpText() {
|
|
|
7
7
|
'',
|
|
8
8
|
'Commands:',
|
|
9
9
|
' login sign in (browser by default; --no-browser for a device code)',
|
|
10
|
+
' --ci sign in from CI via the runner\'s own OIDC identity (no browser/device code)',
|
|
11
|
+
' --oidc-token-file <p> read the OIDC token from a file instead of the CI platform\'s own env',
|
|
12
|
+
' --tenant-id <uuid> the tenant to authenticate against (or set RAKOMI_PLATFORM_TENANT_ID)',
|
|
10
13
|
' logout clear the local session',
|
|
11
|
-
' whoami show the signed-in account',
|
|
14
|
+
' whoami show the signed-in account, home tenant, and active tenant',
|
|
15
|
+
' use <tenant-id> remember a tenant id locally for whoami/future commands (a value, not a verified membership)',
|
|
12
16
|
' connect connect Claude Code / Claude Desktop to your tenant (read access)',
|
|
13
17
|
' --write also request write access for the connected client',
|
|
14
18
|
' --client <name> claude-code | claude-desktop (required if more than one applies)',
|
|
@@ -19,6 +23,7 @@ export function helpText() {
|
|
|
19
23
|
' --owner <me|email> who becomes the new tenant\'s owner (default: me)',
|
|
20
24
|
' --slug <slug> optional; auto-derived + suffixed if omitted',
|
|
21
25
|
' tenants list list tenants you provisioned',
|
|
26
|
+
' mcp tools --ci list MCP read tools your CI identity can invoke (no login needed first)',
|
|
22
27
|
'',
|
|
23
28
|
'Global options:',
|
|
24
29
|
' --json machine-readable output',
|
|
@@ -26,6 +31,7 @@ export function helpText() {
|
|
|
26
31
|
' --dry-run print what would happen, make no writes or mutating calls',
|
|
27
32
|
' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
|
|
28
33
|
' --no-keychain store the session in a 0600 file instead of the OS keychain',
|
|
34
|
+
' --tenant <id> override the active tenant for this invocation (whoami only today)',
|
|
29
35
|
' -h, --help show this help and exit',
|
|
30
36
|
' -V, --version print the version and exit',
|
|
31
37
|
'',
|
package/dist/userinfo-client.js
CHANGED
|
@@ -4,7 +4,8 @@ export async function fetchUserInfo(deps, opts) {
|
|
|
4
4
|
const result = await request(deps, {
|
|
5
5
|
method: 'GET',
|
|
6
6
|
url: `${opts.apiBaseUrl}/oauth/userinfo`,
|
|
7
|
-
headers: { authorization: `Bearer ${opts.accessToken}` },
|
|
7
|
+
headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
8
|
+
dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
|
|
8
9
|
});
|
|
9
10
|
if (result.status === 401) {
|
|
10
11
|
throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
},
|
|
27
27
|
"component": {
|
|
28
28
|
"type": "library",
|
|
29
|
-
"bom-ref": "pkg:npm/rakomi@0.
|
|
29
|
+
"bom-ref": "pkg:npm/rakomi@0.2.0",
|
|
30
30
|
"name": "rakomi",
|
|
31
|
-
"version": "0.
|
|
32
|
-
"purl": "pkg:npm/rakomi@0.
|
|
31
|
+
"version": "0.2.0",
|
|
32
|
+
"purl": "pkg:npm/rakomi@0.2.0",
|
|
33
33
|
"licenses": [
|
|
34
34
|
{
|
|
35
35
|
"license": {
|