rakomi 0.1.2 → 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/commands/login.js +38 -6
- package/dist/commands/mcp.js +45 -0
- package/dist/commands/use.js +10 -0
- package/dist/commands/whoami.js +5 -0
- package/dist/dpop.js +1 -1
- package/dist/env.js +16 -0
- package/dist/index.js +45 -1
- package/dist/install-key.js +22 -13
- package/dist/mcp-tools-ci.js +44 -0
- package/dist/oauth-authcode.js +2 -0
- package/dist/oidc-federation-login.js +2 -3
- package/dist/pkce.js +1 -1
- package/dist/session.js +2 -1
- package/dist/tenant-config.js +50 -0
- package/dist/usage.js +5 -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 |
|
package/dist/commands/login.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { resolveCiOidcToken } from '../ci-oidc-token.js';
|
|
2
|
-
import {
|
|
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';
|
|
3
4
|
import { CliError, EXIT } from '../errors.js';
|
|
4
|
-
import { bindInstallKey, getOrCreateInstallKey, signClientAssertion } from '../install-key.js';
|
|
5
|
+
import { bindInstallKey, getOrCreateInstallKey, peekInstallKey, persistInstallKey, signClientAssertion, } from '../install-key.js';
|
|
5
6
|
import { buildAuthorizeUrl, exchangeAuthorizationCode } from '../oauth-authcode.js';
|
|
6
7
|
import { loginWithDeviceGrant } from '../oauth-device.js';
|
|
7
8
|
import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
|
|
@@ -14,6 +15,25 @@ function cimdUnsupportedMessage(cid) {
|
|
|
14
15
|
'the dashboard, then run `rakomi login --client <your-client-id>` (or set RAKOMI_CLIENT_ID) ' +
|
|
15
16
|
'instead.');
|
|
16
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
|
+
}
|
|
17
37
|
export async function runLogin(deps) {
|
|
18
38
|
const apiUrl = apiBaseUrl(deps.env);
|
|
19
39
|
const cid = resolveClientId(deps.env);
|
|
@@ -29,9 +49,16 @@ export async function runLogin(deps) {
|
|
|
29
49
|
}
|
|
30
50
|
}
|
|
31
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
|
+
}
|
|
32
59
|
const token = deps.noBrowser
|
|
33
60
|
? await runDeviceGrantLogin(deps, apiUrl, cid)
|
|
34
|
-
: await runLoopbackLogin(deps, apiUrl, cid, installKey);
|
|
61
|
+
: await runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId);
|
|
35
62
|
const session = {
|
|
36
63
|
access_token: token.access_token,
|
|
37
64
|
refresh_token: token.refresh_token,
|
|
@@ -39,6 +66,7 @@ export async function runLogin(deps) {
|
|
|
39
66
|
expires_at: deps.now() + token.expires_in * 1000,
|
|
40
67
|
api_base_url: apiUrl,
|
|
41
68
|
client_id: cid,
|
|
69
|
+
...(tenantId ? { home_tenant_id: tenantId } : {}),
|
|
42
70
|
};
|
|
43
71
|
if (token.token_type !== 'Bearer' && token.token_type !== 'DPoP') {
|
|
44
72
|
deps.stdout.write(`(debug) unexpected token_type "${token.token_type}" from the server — treating this session as Bearer.\n`);
|
|
@@ -49,7 +77,7 @@ export async function runLogin(deps) {
|
|
|
49
77
|
await bindInstallKey(deps, deps.keys, { apiBaseUrl: apiUrl, clientId: cid, accessToken: token.access_token, dpopBound: session.token_type === 'DPoP' }, installKey);
|
|
50
78
|
}
|
|
51
79
|
}
|
|
52
|
-
async function runLoopbackLogin(deps, apiUrl, cid, installKey) {
|
|
80
|
+
async function runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId) {
|
|
53
81
|
const listener = await deps.startLoopback();
|
|
54
82
|
try {
|
|
55
83
|
const pkce = generatePkcePair();
|
|
@@ -59,6 +87,7 @@ async function runLoopbackLogin(deps, apiUrl, cid, installKey) {
|
|
|
59
87
|
redirectUri: listener.redirectUri,
|
|
60
88
|
pkce,
|
|
61
89
|
scope: LOGIN_SCOPE,
|
|
90
|
+
tenantId,
|
|
62
91
|
});
|
|
63
92
|
deps.stdout.write(`Opening your browser to sign in:\n ${authorizeUrl}\n`);
|
|
64
93
|
await deps.openBrowser(authorizeUrl);
|
|
@@ -94,7 +123,6 @@ async function runDeviceGrantLogin(deps, apiUrl, cid) {
|
|
|
94
123
|
},
|
|
95
124
|
}, { apiBaseUrl: apiUrl, clientId: cid, scope: LOGIN_SCOPE });
|
|
96
125
|
}
|
|
97
|
-
const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
|
|
98
126
|
export async function runLoginCi(deps) {
|
|
99
127
|
const apiUrl = apiBaseUrl(deps.env);
|
|
100
128
|
const subjectToken = await resolveCiOidcToken(deps, {
|
|
@@ -103,7 +131,11 @@ export async function runLoginCi(deps) {
|
|
|
103
131
|
readTextFile: deps.readTextFile,
|
|
104
132
|
audience: apiUrl,
|
|
105
133
|
});
|
|
106
|
-
const
|
|
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);
|
|
107
139
|
const session = {
|
|
108
140
|
access_token: token.access_token,
|
|
109
141
|
token_type: token.token_type,
|
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -7,6 +7,7 @@ export async function runWhoami(deps) {
|
|
|
7
7
|
throw new NotLoggedInError();
|
|
8
8
|
const dpop = resolveDpopKey(deps.keys, session);
|
|
9
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;
|
|
10
11
|
if (deps.json) {
|
|
11
12
|
deps.stdout.write(JSON.stringify({
|
|
12
13
|
sub: info.sub,
|
|
@@ -15,6 +16,8 @@ export async function runWhoami(deps) {
|
|
|
15
16
|
org_role: info.org_role,
|
|
16
17
|
session_store: deps.session.describePath(),
|
|
17
18
|
token_type: session.token_type,
|
|
19
|
+
home_tenant_id: session.home_tenant_id,
|
|
20
|
+
active_tenant_id: activeTenantId,
|
|
18
21
|
}) + '\n');
|
|
19
22
|
return;
|
|
20
23
|
}
|
|
@@ -22,6 +25,8 @@ export async function runWhoami(deps) {
|
|
|
22
25
|
if (info.org_id)
|
|
23
26
|
lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
|
|
24
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>`)'}`);
|
|
25
30
|
lines.push(`Session stored in: ${deps.session.describePath()}`);
|
|
26
31
|
lines.push(`Token type: ${session.token_type}`);
|
|
27
32
|
deps.stdout.write(lines.join('\n') + '\n');
|
package/dist/dpop.js
CHANGED
|
@@ -6,7 +6,7 @@ export function generateEphemeralDpopKey() {
|
|
|
6
6
|
return { privateKeyPem, publicJwk };
|
|
7
7
|
}
|
|
8
8
|
function base64url(buf) {
|
|
9
|
-
return buf.toString('
|
|
9
|
+
return buf.toString('base64url');
|
|
10
10
|
}
|
|
11
11
|
export function canonicalizeHtu(input) {
|
|
12
12
|
const url = new URL(input);
|
package/dist/env.js
CHANGED
|
@@ -3,6 +3,7 @@ export const DEFAULT_API_BASE_URL = 'https://api.rakomi.com';
|
|
|
3
3
|
export const DEFAULT_ACCOUNTS_BASE_URL = 'https://accounts.rakomi.com';
|
|
4
4
|
export const DEFAULT_MCP_URL = 'https://mcp.rakomi.com/mcp';
|
|
5
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';
|
|
6
7
|
export function isCimdClientId(id) {
|
|
7
8
|
let url;
|
|
8
9
|
try {
|
|
@@ -22,6 +23,21 @@ export function accountsBaseUrl(env) {
|
|
|
22
23
|
export function clientId(env) {
|
|
23
24
|
return env.RAKOMI_CLIENT_ID || DEFAULT_CIMD_CLIENT_ID_URL;
|
|
24
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
|
+
}
|
|
25
41
|
const CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD = 'client_id_metadata_document_supported';
|
|
26
42
|
export async function isCimdPlatformSupported(deps, apiUrl) {
|
|
27
43
|
try {
|
package/dist/index.js
CHANGED
|
@@ -8,12 +8,15 @@ import { systemBrowserOpener } from './browser.js';
|
|
|
8
8
|
import { runConnect } from './commands/connect.js';
|
|
9
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' },
|
|
@@ -28,6 +31,8 @@ const GLOBAL_OPTIONS = {
|
|
|
28
31
|
write: { type: 'boolean' },
|
|
29
32
|
owner: { type: 'string' },
|
|
30
33
|
slug: { type: 'string' },
|
|
34
|
+
'tenant-id': { type: 'string' },
|
|
35
|
+
tenant: { type: 'string' },
|
|
31
36
|
'cimd-url': { type: 'string' },
|
|
32
37
|
status: { type: 'boolean' },
|
|
33
38
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -82,6 +87,7 @@ async function dispatch(args, deps) {
|
|
|
82
87
|
env: deps.env,
|
|
83
88
|
ciEnv: deps.ciEnv ?? {},
|
|
84
89
|
session: deps.session,
|
|
90
|
+
keys: deps.keys,
|
|
85
91
|
oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
|
|
86
92
|
readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
|
|
87
93
|
stdout: deps.stdout,
|
|
@@ -100,6 +106,7 @@ async function dispatch(args, deps) {
|
|
|
100
106
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
101
107
|
stdout: deps.stdout,
|
|
102
108
|
now: () => Date.now(),
|
|
109
|
+
explicitTenantId: typeof values['tenant-id'] === 'string' ? values['tenant-id'] : undefined,
|
|
103
110
|
});
|
|
104
111
|
return EXIT.OK;
|
|
105
112
|
}
|
|
@@ -107,8 +114,23 @@ async function dispatch(args, deps) {
|
|
|
107
114
|
runLogout({ session: deps.session, stdout: deps.stdout });
|
|
108
115
|
return EXIT.OK;
|
|
109
116
|
case 'whoami':
|
|
110
|
-
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 });
|
|
111
132
|
return EXIT.OK;
|
|
133
|
+
}
|
|
112
134
|
case 'connect':
|
|
113
135
|
await runConnect({
|
|
114
136
|
...httpDeps,
|
|
@@ -128,6 +150,26 @@ async function dispatch(args, deps) {
|
|
|
128
150
|
status: values.status === true,
|
|
129
151
|
});
|
|
130
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
|
+
}
|
|
131
173
|
case 'tenants': {
|
|
132
174
|
const [sub, ...tenantArgs] = rest;
|
|
133
175
|
if (sub === 'create') {
|
|
@@ -170,6 +212,7 @@ async function main() {
|
|
|
170
212
|
RAKOMI_ACCOUNTS_URL: process.env.RAKOMI_ACCOUNTS_URL,
|
|
171
213
|
RAKOMI_CLIENT_ID: process.env.RAKOMI_CLIENT_ID,
|
|
172
214
|
RAKOMI_CONFIG_DIR: process.env.RAKOMI_CONFIG_DIR,
|
|
215
|
+
RAKOMI_PLATFORM_TENANT_ID: process.env.RAKOMI_PLATFORM_TENANT_ID,
|
|
173
216
|
CI: process.env.CI,
|
|
174
217
|
NO_COLOR: process.env.NO_COLOR,
|
|
175
218
|
};
|
|
@@ -194,6 +237,7 @@ async function main() {
|
|
|
194
237
|
fetchImpl: fetch,
|
|
195
238
|
session: resolved.session,
|
|
196
239
|
keys: resolved.keys,
|
|
240
|
+
tenantConfig: new FileTenantConfigStore(env),
|
|
197
241
|
detectClaudeCode: () => detectClaudeCodeCli(),
|
|
198
242
|
ciEnv,
|
|
199
243
|
});
|
package/dist/install-key.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createPrivateKey, generateKeyPairSync, randomUUID, sign as cryptoSign } from 'node:crypto';
|
|
2
|
-
import { isCimdClientId } from './env.js';
|
|
2
|
+
import { CI_FEDERATION_SESSION_CLIENT_ID, isCimdClientId } from './env.js';
|
|
3
3
|
import { request } from './http.js';
|
|
4
4
|
const INSTALL_KEY_NAME = 'install-key';
|
|
5
5
|
const ASSERTION_LIFETIME_SEC = 60;
|
|
@@ -19,23 +19,32 @@ function generateKeyPair(clientId) {
|
|
|
19
19
|
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
20
20
|
return { clientId, privateKeyPem, publicJwk, confirmedBound: false };
|
|
21
21
|
}
|
|
22
|
-
export function
|
|
22
|
+
export function peekInstallKey(keys, clientId) {
|
|
23
23
|
const raw = keys.get(INSTALL_KEY_NAME);
|
|
24
|
-
if (raw
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
catch {
|
|
31
|
-
}
|
|
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;
|
|
32
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;
|
|
33
42
|
const fresh = generateKeyPair(clientId);
|
|
34
|
-
keys
|
|
43
|
+
persistInstallKey(keys, fresh);
|
|
35
44
|
return fresh;
|
|
36
45
|
}
|
|
37
46
|
function base64url(buf) {
|
|
38
|
-
return buf.toString('
|
|
47
|
+
return buf.toString('base64url');
|
|
39
48
|
}
|
|
40
49
|
export function signClientAssertion(key, opts) {
|
|
41
50
|
const privateKey = createPrivateKey(key.privateKeyPem);
|
|
@@ -79,7 +88,7 @@ export async function bindInstallKey(deps, keys, opts, key) {
|
|
|
79
88
|
export function resolveDpopKey(keys, session) {
|
|
80
89
|
if (session.token_type !== 'DPoP')
|
|
81
90
|
return undefined;
|
|
82
|
-
if (!isCimdClientId(session.client_id))
|
|
91
|
+
if (!isCimdClientId(session.client_id) && session.client_id !== CI_FEDERATION_SESSION_CLIENT_ID)
|
|
83
92
|
return undefined;
|
|
84
93
|
return getOrCreateInstallKey(keys, session.client_id);
|
|
85
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) {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { buildDpopProof, canonicalizeHtu
|
|
1
|
+
import { buildDpopProof, canonicalizeHtu } from './dpop.js';
|
|
2
2
|
import { CliError, EXIT } from './errors.js';
|
|
3
3
|
import { describeError, request } from './http.js';
|
|
4
4
|
export const OIDC_FEDERATION_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
|
|
5
5
|
export async function exchangeOidcSubjectToken(deps, opts) {
|
|
6
6
|
const tokenUrl = `${opts.apiBaseUrl}/oauth/token`;
|
|
7
|
-
const
|
|
8
|
-
const proof = buildDpopProof(dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
|
|
7
|
+
const proof = buildDpopProof(opts.dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
|
|
9
8
|
const result = await request(deps, {
|
|
10
9
|
method: 'POST',
|
|
11
10
|
url: tokenUrl,
|
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
|
@@ -98,7 +98,8 @@ function isStoredSession(value) {
|
|
|
98
98
|
(v.token_type === 'Bearer' || v.token_type === 'DPoP') &&
|
|
99
99
|
typeof v.expires_at === 'number' &&
|
|
100
100
|
typeof v.api_base_url === 'string' &&
|
|
101
|
-
typeof v.client_id === 'string'
|
|
101
|
+
typeof v.client_id === 'string' &&
|
|
102
|
+
(v.home_tenant_id === undefined || typeof v.home_tenant_id === 'string'));
|
|
102
103
|
}
|
|
103
104
|
export function isExpired(session, now) {
|
|
104
105
|
return now >= session.expires_at;
|
|
@@ -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/usage.js
CHANGED
|
@@ -9,8 +9,10 @@ export function helpText() {
|
|
|
9
9
|
' login sign in (browser by default; --no-browser for a device code)',
|
|
10
10
|
' --ci sign in from CI via the runner\'s own OIDC identity (no browser/device code)',
|
|
11
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)',
|
|
12
13
|
' logout clear the local session',
|
|
13
|
-
' 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)',
|
|
14
16
|
' connect connect Claude Code / Claude Desktop to your tenant (read access)',
|
|
15
17
|
' --write also request write access for the connected client',
|
|
16
18
|
' --client <name> claude-code | claude-desktop (required if more than one applies)',
|
|
@@ -21,6 +23,7 @@ export function helpText() {
|
|
|
21
23
|
' --owner <me|email> who becomes the new tenant\'s owner (default: me)',
|
|
22
24
|
' --slug <slug> optional; auto-derived + suffixed if omitted',
|
|
23
25
|
' tenants list list tenants you provisioned',
|
|
26
|
+
' mcp tools --ci list MCP read tools your CI identity can invoke (no login needed first)',
|
|
24
27
|
'',
|
|
25
28
|
'Global options:',
|
|
26
29
|
' --json machine-readable output',
|
|
@@ -28,6 +31,7 @@ export function helpText() {
|
|
|
28
31
|
' --dry-run print what would happen, make no writes or mutating calls',
|
|
29
32
|
' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
|
|
30
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)',
|
|
31
35
|
' -h, --help show this help and exit',
|
|
32
36
|
' -V, --version print the version and exit',
|
|
33
37
|
'',
|
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": {
|