@athsra/cli 1.0.2 → 1.0.4
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/package.json +2 -3
- package/src/commands/admin.ts +11 -33
- package/src/commands/adopt.ts +1 -5
- package/src/commands/dr.ts +217 -0
- package/src/commands/login.ts +223 -179
- package/src/commands/manifest.ts +3 -10
- package/src/commands/mcp.ts +69 -485
- package/src/commands/new-phrase.ts +1 -1
- package/src/commands/org.ts +363 -0
- package/src/commands/rotate-master.ts +26 -7
- package/src/commands/run.ts +2 -1
- package/src/commands/service-token.ts +17 -6
- package/src/index.ts +7 -0
- package/src/lib/auth-context.ts +77 -18
- package/src/lib/client.ts +396 -31
- package/src/lib/colors.ts +17 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/env-format.ts +5 -53
- package/src/lib/envelope.ts +89 -3
- package/src/lib/identity-key.ts +59 -0
- package/src/lib/keyring.ts +26 -0
- package/src/lib/mcp-tools/args.ts +60 -0
- package/src/lib/mcp-tools/defs.ts +179 -0
- package/src/lib/mcp-tools/read.ts +156 -0
- package/src/lib/mcp-tools/result.ts +46 -0
- package/src/lib/mcp-tools/write.ts +127 -0
- package/src/lib/oidc-flow.ts +200 -0
- package/src/lib/org-rewrap.ts +230 -0
- package/src/lib/secrets-manifest.ts +1 -4
- package/src/lib/wrangler-scan.ts +2 -8
- package/src/lib/bip39.ts +0 -45
package/src/lib/envelope.ts
CHANGED
|
@@ -16,16 +16,66 @@ import {
|
|
|
16
16
|
decryptEnvelopeV2,
|
|
17
17
|
deriveKey,
|
|
18
18
|
fromBase64,
|
|
19
|
+
type KDFParams,
|
|
20
|
+
reEncryptEnvelopeBody,
|
|
19
21
|
type SecretEnvelopeAny,
|
|
20
22
|
type SecretEnvelopeV2,
|
|
21
23
|
} from '@athsra/crypto';
|
|
22
|
-
import
|
|
24
|
+
import {
|
|
25
|
+
decryptEnvelopeAsMember,
|
|
26
|
+
reEncryptAsMember,
|
|
27
|
+
unwrapPrivateKey,
|
|
28
|
+
} from '@athsra/crypto/member';
|
|
29
|
+
import type { AuthContext, UserAuthContext } from './auth-context.ts';
|
|
23
30
|
import { parseEnv, serializeEnv } from './env-format.ts';
|
|
24
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Slice 6c — owner master pw 가 아닌 멤버(admin role)의 X25519 private key 를 fetch+unwrap.
|
|
34
|
+
* GET /auth/keys 의 wrapped privkey 를 자기 master pw 로 푼다 (worker 는 평문 privkey 못 봄).
|
|
35
|
+
* 키 없거나 pw 불일치면 null.
|
|
36
|
+
*/
|
|
37
|
+
async function memberPrivateKey(ctx: UserAuthContext): Promise<Uint8Array | null> {
|
|
38
|
+
const keyRow = await ctx.client.getKeys();
|
|
39
|
+
if (!keyRow) return null;
|
|
40
|
+
try {
|
|
41
|
+
return await unwrapPrivateKey(
|
|
42
|
+
{
|
|
43
|
+
wrappedPrivateKey: keyRow.wrapped_private_key,
|
|
44
|
+
keySalt: keyRow.key_salt,
|
|
45
|
+
wrapNonce: keyRow.wrap_nonce,
|
|
46
|
+
kdfParams: keyRow.kdf_params,
|
|
47
|
+
},
|
|
48
|
+
ctx.masterPw,
|
|
49
|
+
);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** env 에 내 member recipient 가 있으면 내 userId 반환 (member 경로 자격). 아니면 null. */
|
|
56
|
+
async function memberRecipientUserId(
|
|
57
|
+
env: SecretEnvelopeV2,
|
|
58
|
+
ctx: UserAuthContext,
|
|
59
|
+
): Promise<number | null> {
|
|
60
|
+
const me = await ctx.client.whoami();
|
|
61
|
+
if (me.userId === undefined) return null;
|
|
62
|
+
return env.recipients.some((r) => r.id === `member:${me.userId}`) ? me.userId : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
async function decryptEnvelope(env: SecretEnvelopeAny, ctx: AuthContext): Promise<string> {
|
|
26
66
|
if (ctx.kind === 'user') {
|
|
27
67
|
if (env.version === 2) {
|
|
28
|
-
|
|
68
|
+
try {
|
|
69
|
+
return await decryptEnvelopeV2(env, ctx.masterPw, 'master');
|
|
70
|
+
} catch (masterErr) {
|
|
71
|
+
// Slice 6c — master(owner) 복호 실패: 내가 member recipient 면 내 X25519 키로 복호
|
|
72
|
+
// (org 공유 시크릿을 owner master pw 없이). 멤버 아니면 원 에러(잘못된 master pw).
|
|
73
|
+
const memberUserId = await memberRecipientUserId(env, ctx);
|
|
74
|
+
if (memberUserId === null) throw masterErr;
|
|
75
|
+
const priv = await memberPrivateKey(ctx);
|
|
76
|
+
if (!priv) throw masterErr;
|
|
77
|
+
return decryptEnvelopeAsMember(env, priv, memberUserId);
|
|
78
|
+
}
|
|
29
79
|
}
|
|
30
80
|
const key = deriveKey(ctx.masterPw, fromBase64(env.salt), env.kdf_params);
|
|
31
81
|
return decrypt(key, {
|
|
@@ -66,11 +116,47 @@ export async function writePlain(
|
|
|
66
116
|
ctx: AuthContext,
|
|
67
117
|
project: string,
|
|
68
118
|
plain: Record<string, string>,
|
|
119
|
+
opts?: { kdfParams?: KDFParams },
|
|
69
120
|
): Promise<SecretEnvelopeV2> {
|
|
70
121
|
if (ctx.kind !== 'user') {
|
|
71
122
|
throw new Error('service token cannot write envelopes — use a user token (master pw)');
|
|
72
123
|
}
|
|
73
|
-
const
|
|
124
|
+
const serialized = serializeEnv(plain);
|
|
125
|
+
|
|
126
|
+
// opts.kdfParams 명시 = rotate-master 의 의도적 reset (ENTERPRISE_KDF 마이그레이션):
|
|
127
|
+
// 새 envelope 를 만들어 모든 service recipient 를 의도적으로 폐기 (master pw 재확립
|
|
128
|
+
// = scoped 신뢰 reset). routine write 와 구분되는 유일한 신호.
|
|
129
|
+
if (opts?.kdfParams) {
|
|
130
|
+
const env = await createEnvelopeV2(serialized, ctx.masterPw, { kdfParams: opts.kdfParams });
|
|
131
|
+
await ctx.client.putEnvelope(project, env);
|
|
132
|
+
return env;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// routine write (set/unset/mcp/sync): 기존 v2 envelope 가 있으면 본문만 재암호화해
|
|
136
|
+
// kdf_params 와 모든 recipient (service token 포함) 를 보존한다. createEnvelopeV2 로
|
|
137
|
+
// 새로 만들면 service recipient 가 silent 삭제되고 kdf 가 DEFAULT(64MB) 로 downgrade 됨.
|
|
138
|
+
const existing = await ctx.client.getEnvelope(project);
|
|
139
|
+
if (existing && existing.version === 2) {
|
|
140
|
+
try {
|
|
141
|
+
const env = await reEncryptEnvelopeBody(existing, ctx.masterPw, serialized);
|
|
142
|
+
await ctx.client.putEnvelope(project, env);
|
|
143
|
+
return env;
|
|
144
|
+
} catch (masterErr) {
|
|
145
|
+
// Slice 6c — master(owner) 가 아니면 member 경로: 내 X25519 키로 DEK 얻어 본문만 재암호
|
|
146
|
+
// (recipients 보존 — server recipient-continuity 가 body-only 로 허용). 멤버 아니면 원 에러.
|
|
147
|
+
const memberUserId = await memberRecipientUserId(existing, ctx);
|
|
148
|
+
if (memberUserId === null) throw masterErr;
|
|
149
|
+
const priv = await memberPrivateKey(ctx);
|
|
150
|
+
if (!priv) throw masterErr;
|
|
151
|
+
const env = await reEncryptAsMember(existing, priv, memberUserId, serialized);
|
|
152
|
+
await ctx.client.putEnvelope(project, env);
|
|
153
|
+
return env;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 신규 프로젝트 또는 v1 (recipients[] 없음) → 새 v2 (DEFAULT_KDF). v1 의 enterprise
|
|
158
|
+
// 마이그레이션은 rotate-master / service-token create (migrateV1ToV2) 경로가 담당.
|
|
159
|
+
const env = await createEnvelopeV2(serialized, ctx.masterPw);
|
|
74
160
|
await ctx.client.putEnvelope(project, env);
|
|
75
161
|
return env;
|
|
76
162
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* identity-key.ts — Phase 4 Slice 3 CLI 측 X25519 identity 키쌍 provisioning/rotation.
|
|
3
|
+
*
|
|
4
|
+
* login 시 키쌍이 없으면 생성(ensureKeypair) + master pw 로 wrap 해 server 에 저장(평문 privkey·
|
|
5
|
+
* pw 는 server 미노출). rotate-master 시 기존 privkey 를 새 pw 로 재포장(rewrapForRotation).
|
|
6
|
+
* crypto 는 @athsra/crypto/member (subpath — worker 번들 격리).
|
|
7
|
+
*/
|
|
8
|
+
import { toBase64 } from '@athsra/crypto';
|
|
9
|
+
import { generateIdentityKeypair, unwrapPrivateKey, wrapPrivateKey } from '@athsra/crypto/member';
|
|
10
|
+
import type { AthsraClient, IdentityKeyRewrap } from './client.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* identity 키쌍 보장 (멱등). 이미 있으면 no-op, 없으면 생성 + wrap + POST. login(register/SSO)
|
|
14
|
+
* 직후 호출. 실패해도 login 자체는 성공으로 두되 호출측이 경고 — 키쌍은 다음 login 에 재시도된다
|
|
15
|
+
* (provisioning 은 best-effort substrate, 실제 공유는 Slice 6).
|
|
16
|
+
*/
|
|
17
|
+
export async function ensureKeypair(client: AthsraClient, masterPw: string): Promise<boolean> {
|
|
18
|
+
const existing = await client.getKeys();
|
|
19
|
+
if (existing) return false;
|
|
20
|
+
const kp = generateIdentityKeypair();
|
|
21
|
+
const wrapped = await wrapPrivateKey(kp.privateKey, masterPw);
|
|
22
|
+
await client.setKeys({
|
|
23
|
+
public_key: toBase64(kp.publicKey),
|
|
24
|
+
wrapped_private_key: wrapped.wrappedPrivateKey,
|
|
25
|
+
key_salt: wrapped.keySalt,
|
|
26
|
+
wrap_nonce: wrapped.wrapNonce,
|
|
27
|
+
kdf_params: wrapped.kdfParams,
|
|
28
|
+
});
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* rotate-master 용 — 기존 identity privkey 를 old pw 로 unwrap → new pw 로 재포장한 재료(없으면
|
|
34
|
+
* null). pubkey 불변. rotateMaster body 로 전달돼 proof 회전과 원자적으로 갱신된다.
|
|
35
|
+
*/
|
|
36
|
+
export async function rewrapForRotation(
|
|
37
|
+
client: AthsraClient,
|
|
38
|
+
oldPw: string,
|
|
39
|
+
newPw: string,
|
|
40
|
+
): Promise<IdentityKeyRewrap | null> {
|
|
41
|
+
const existing = await client.getKeys();
|
|
42
|
+
if (!existing) return null;
|
|
43
|
+
const privateKey = await unwrapPrivateKey(
|
|
44
|
+
{
|
|
45
|
+
wrappedPrivateKey: existing.wrapped_private_key,
|
|
46
|
+
keySalt: existing.key_salt,
|
|
47
|
+
wrapNonce: existing.wrap_nonce,
|
|
48
|
+
kdfParams: existing.kdf_params,
|
|
49
|
+
},
|
|
50
|
+
oldPw,
|
|
51
|
+
);
|
|
52
|
+
const rewrapped = await wrapPrivateKey(privateKey, newPw);
|
|
53
|
+
return {
|
|
54
|
+
wrapped_private_key: rewrapped.wrappedPrivateKey,
|
|
55
|
+
key_salt: rewrapped.keySalt,
|
|
56
|
+
wrap_nonce: rewrapped.wrapNonce,
|
|
57
|
+
kdf_params: rewrapped.kdfParams,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/lib/keyring.ts
CHANGED
|
@@ -46,6 +46,32 @@ export function clearToken(machineId: string): void {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Phase 3 P3 (2026-05-31) — device-login 으로 발급된 project-scoped service token (ats_*).
|
|
51
|
+
*
|
|
52
|
+
* machine 전역의 user token (master-pw + token) 과 별개. 비-TTY agent 가 master pw 없이
|
|
53
|
+
* `athsra run <project>` 하도록, project 별 slot 에 저장. loadAuthContext(project) 가 조회.
|
|
54
|
+
*/
|
|
55
|
+
export function setDeviceToken(project: string, token: string): void {
|
|
56
|
+
entry(`device-token:${project}`).setPassword(token);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getDeviceToken(project: string): string | null {
|
|
60
|
+
try {
|
|
61
|
+
return entry(`device-token:${project}`).getPassword();
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function clearDeviceToken(project: string): void {
|
|
68
|
+
try {
|
|
69
|
+
entry(`device-token:${project}`).deletePassword();
|
|
70
|
+
} catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
49
75
|
export interface ProbeResult {
|
|
50
76
|
ok: boolean;
|
|
51
77
|
error?: string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools/args.ts — MCP tool 인자 파싱 + worker 선택 헬퍼.
|
|
3
|
+
*
|
|
4
|
+
* `commands/mcp.ts` 에서 분리 (2026-06-02 리팩토링). read/write 핸들러 공용. 동작 보존.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { WrangerWorker } from '../adopt-context.ts';
|
|
8
|
+
|
|
9
|
+
export function pickWorker(
|
|
10
|
+
workers: WrangerWorker[],
|
|
11
|
+
explicit?: string,
|
|
12
|
+
): WrangerWorker | { error: string } {
|
|
13
|
+
if (explicit) {
|
|
14
|
+
const found = workers.find((w) => w.name === explicit);
|
|
15
|
+
if (found) return found;
|
|
16
|
+
const names = workers.map((w) => w.name).join(', ') || '(none)';
|
|
17
|
+
return { error: `worker '${explicit}' not found. available: ${names}` };
|
|
18
|
+
}
|
|
19
|
+
if (workers.length === 0) {
|
|
20
|
+
return { error: 'no wrangler.jsonc found in cwd' };
|
|
21
|
+
}
|
|
22
|
+
if (workers.length === 1) {
|
|
23
|
+
const [only] = workers;
|
|
24
|
+
if (!only) return { error: 'no wrangler.jsonc found' };
|
|
25
|
+
return only;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
error: `multiple workers found. specify worker: ${workers.map((w) => w.name).join(', ')}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readString(
|
|
33
|
+
args: Record<string, unknown> | undefined,
|
|
34
|
+
key: string,
|
|
35
|
+
): string | undefined {
|
|
36
|
+
const v = args?.[key];
|
|
37
|
+
return typeof v === 'string' ? v : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readBoolean(
|
|
41
|
+
args: Record<string, unknown> | undefined,
|
|
42
|
+
key: string,
|
|
43
|
+
): boolean | undefined {
|
|
44
|
+
const v = args?.[key];
|
|
45
|
+
return typeof v === 'boolean' ? v : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readStringArray(
|
|
49
|
+
args: Record<string, unknown> | undefined,
|
|
50
|
+
key: string,
|
|
51
|
+
): string[] | undefined {
|
|
52
|
+
const v = args?.[key];
|
|
53
|
+
if (!Array.isArray(v)) return undefined;
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
for (const item of v) {
|
|
56
|
+
if (typeof item !== 'string') return undefined;
|
|
57
|
+
out.push(item);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools/defs.ts — MCP tool 정의 (JSON Schema literal).
|
|
3
|
+
*
|
|
4
|
+
* `commands/mcp.ts` 에서 분리 (2026-06-02 리팩토링) — tool 정의·핸들러를 lib/mcp-tools/ 로
|
|
5
|
+
* 추출. mcp.ts 는 orchestration (dispatch/buildServer/mcpCmd/__test) 만 유지. 동작 보존.
|
|
6
|
+
*
|
|
7
|
+
* 정공법: McpServer high-level 의 zod generic 이 tsc inference OOM (SIGABRT) 유발 →
|
|
8
|
+
* JSON Schema literal 로 정의, zod 의존성 제거 (mcp.ts 의 low-level Server dispatch 와 정합).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ToolDef {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
inputSchema: Record<string, unknown>;
|
|
15
|
+
annotations?: { destructiveHint?: boolean; readOnlyHint?: boolean };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const READ_TOOLS: ToolDef[] = [
|
|
19
|
+
{
|
|
20
|
+
name: 'athsra_whoami',
|
|
21
|
+
description:
|
|
22
|
+
'Check athsra authentication status for THIS machine. Call this FIRST when onboarding athsra. ' +
|
|
23
|
+
'Returns { authenticated, userId, machineId } (identity only, NO secrets). If not authenticated, ' +
|
|
24
|
+
'returns the exact command to run: `athsra login --sso` opens a browser to sign up or log in ' +
|
|
25
|
+
'(modfolio Connect SSO). All other athsra tools require authentication first.',
|
|
26
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
27
|
+
annotations: { readOnlyHint: true },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'athsra_list_projects',
|
|
31
|
+
description:
|
|
32
|
+
'List all envelope (project) names accessible to the current athsra user. ' +
|
|
33
|
+
'Requires prior `athsra login`. Returns string array of project names.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
detail: {
|
|
38
|
+
type: 'boolean',
|
|
39
|
+
description: 'If true, return extended rows (versions, last update, etc.).',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'athsra_get_project_keys',
|
|
47
|
+
description:
|
|
48
|
+
'Return the list of secret keys stored in a given envelope. ' +
|
|
49
|
+
'Values are NOT returned for security — only key names. ' +
|
|
50
|
+
'Use `athsra run <project> -- <cmd>` (outside MCP) to inject values into a process.',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
project: { type: 'string', minLength: 1, description: 'Envelope name.' },
|
|
55
|
+
},
|
|
56
|
+
required: ['project'],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'athsra_show_manifest',
|
|
62
|
+
description:
|
|
63
|
+
'Read the secrets manifest (.athsra/secrets.json) for a sibling worker. ' +
|
|
64
|
+
'The manifest enumerates the keys that opt-in to wrangler secrets sync ' +
|
|
65
|
+
'(Phase 2.6 default-deny / Option γ). Resolves worker by `cwd` argument; ' +
|
|
66
|
+
'falls back to process cwd if absent.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
cwd: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: 'Path inside the sibling repo (default: process cwd).',
|
|
73
|
+
},
|
|
74
|
+
worker: {
|
|
75
|
+
type: 'string',
|
|
76
|
+
description: 'CF worker name if multiple wrangler.jsonc are present.',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'athsra_validate_manifest',
|
|
84
|
+
description:
|
|
85
|
+
'Compare a sibling worker manifest against the envelope to identify onboarding gaps. ' +
|
|
86
|
+
'Returns three sets: `allowed` (manifest ∩ envelope — will sync), ' +
|
|
87
|
+
'`missing` (manifest \\ envelope — onboarding gap, sibling needs `athsra set`), ' +
|
|
88
|
+
'`excluded` (envelope \\ manifest — intentionally not synced).',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
project: { type: 'string', minLength: 1 },
|
|
93
|
+
cwd: { type: 'string' },
|
|
94
|
+
worker: { type: 'string' },
|
|
95
|
+
},
|
|
96
|
+
required: ['project'],
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
export const WRITE_TOOLS: ToolDef[] = [
|
|
103
|
+
{
|
|
104
|
+
name: 'athsra_set_secret',
|
|
105
|
+
description:
|
|
106
|
+
'Add or overwrite a secret key=value in an envelope (E2EE encrypted with master pw). ' +
|
|
107
|
+
'DESTRUCTIVE — invoke only with explicit user intent. Value is NEVER echoed back. ' +
|
|
108
|
+
'Use this to onboard new keys identified by athsra_validate_manifest missing list.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
project: { type: 'string', minLength: 1 },
|
|
113
|
+
key: { type: 'string', minLength: 1, pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
|
|
114
|
+
value: { type: 'string', description: 'Secret value (not logged, not echoed).' },
|
|
115
|
+
},
|
|
116
|
+
required: ['project', 'key', 'value'],
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
},
|
|
119
|
+
annotations: { destructiveHint: true, readOnlyHint: false },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'athsra_unset_secret',
|
|
123
|
+
description:
|
|
124
|
+
'Remove a secret key from an envelope. DESTRUCTIVE. Value is irrecoverable unless ' +
|
|
125
|
+
'a prior version is restored via `athsra rollback`.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: {
|
|
129
|
+
project: { type: 'string', minLength: 1 },
|
|
130
|
+
key: { type: 'string', minLength: 1 },
|
|
131
|
+
},
|
|
132
|
+
required: ['project', 'key'],
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
},
|
|
135
|
+
annotations: { destructiveHint: true, readOnlyHint: false },
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'athsra_manifest_init',
|
|
139
|
+
description:
|
|
140
|
+
'Create a new sibling worker manifest (.athsra/secrets.json) with the given keys. ' +
|
|
141
|
+
'Fails if manifest already exists (use `athsra_manifest_modify` for updates). ' +
|
|
142
|
+
'Honors host repo biome.json indent style (Phase 2.6.1).',
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
properties: {
|
|
146
|
+
cwd: { type: 'string' },
|
|
147
|
+
worker: { type: 'string' },
|
|
148
|
+
keys: {
|
|
149
|
+
type: 'array',
|
|
150
|
+
items: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
|
|
151
|
+
minItems: 1,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
required: ['keys'],
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
},
|
|
157
|
+
annotations: { destructiveHint: true, readOnlyHint: false },
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'athsra_manifest_modify',
|
|
161
|
+
description: "Add or remove keys from an existing sibling manifest. `op` = 'add' or 'remove'.",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
op: { type: 'string', enum: ['add', 'remove'] },
|
|
166
|
+
cwd: { type: 'string' },
|
|
167
|
+
worker: { type: 'string' },
|
|
168
|
+
keys: {
|
|
169
|
+
type: 'array',
|
|
170
|
+
items: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
|
|
171
|
+
minItems: 1,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
required: ['op', 'keys'],
|
|
175
|
+
additionalProperties: false,
|
|
176
|
+
},
|
|
177
|
+
annotations: { destructiveHint: true, readOnlyHint: false },
|
|
178
|
+
},
|
|
179
|
+
];
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools/read.ts — read-only MCP tool 핸들러 (항상 노출, secret 값 미노출).
|
|
3
|
+
*
|
|
4
|
+
* `commands/mcp.ts` 에서 분리 (2026-06-02 리팩토링). 동작 보존.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { inferAdoptContext } from '../adopt-context.ts';
|
|
8
|
+
import { loadAuthContext } from '../auth-context.ts';
|
|
9
|
+
import { readPlain } from '../envelope.ts';
|
|
10
|
+
import { applyManifest, loadManifest } from '../secrets-manifest.ts';
|
|
11
|
+
import { pickWorker, readBoolean, readString } from './args.ts';
|
|
12
|
+
import { isAuthError, jsonError, jsonOk, notLoggedIn, type ToolTextResult } from './result.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* athsra_whoami — 현재 머신의 인증 상태 + 신원(userId·machineId·worker URL, 시크릿 X). AI 가 athsra
|
|
16
|
+
* 온보딩 시 **가장 먼저** 호출해 로그인 필요 여부를 판단한다. 미인증이면 정확한 로그인 명령을 돌려준다
|
|
17
|
+
* (성공 결과로 — 에러 아님, AI 가 상태를 명확히 읽도록).
|
|
18
|
+
*/
|
|
19
|
+
export async function handleWhoami(): Promise<ToolTextResult> {
|
|
20
|
+
const ctx = await loadAuthContext();
|
|
21
|
+
if (!ctx) {
|
|
22
|
+
return jsonOk({
|
|
23
|
+
authenticated: false,
|
|
24
|
+
action:
|
|
25
|
+
'Run `athsra login --sso` in a terminal — a browser opens to sign up or log in (modfolio Connect SSO).',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const me = await ctx.client.whoami();
|
|
30
|
+
return jsonOk({
|
|
31
|
+
authenticated: true,
|
|
32
|
+
kind: ctx.kind,
|
|
33
|
+
userId: me.userId,
|
|
34
|
+
orgId: me.orgId,
|
|
35
|
+
machineId: me.machineId,
|
|
36
|
+
label: me.label,
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// 토큰은 있으나 세션 만료(idle timeout)/401 → 미인증으로 보고 + 재로그인 안내.
|
|
40
|
+
if (isAuthError(err)) {
|
|
41
|
+
return jsonOk({
|
|
42
|
+
authenticated: false,
|
|
43
|
+
reason: 'session expired or idle timeout',
|
|
44
|
+
action: 'Run `athsra login --sso` to re-authenticate (a browser opens).',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return jsonError(`whoami failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function handleListProjects(
|
|
52
|
+
args: Record<string, unknown> | undefined,
|
|
53
|
+
): Promise<ToolTextResult> {
|
|
54
|
+
const ctx = await loadAuthContext();
|
|
55
|
+
if (!ctx) {
|
|
56
|
+
return notLoggedIn();
|
|
57
|
+
}
|
|
58
|
+
const detail = readBoolean(args, 'detail');
|
|
59
|
+
if (detail) {
|
|
60
|
+
const rows = await ctx.client.listProjectsExtended();
|
|
61
|
+
return jsonOk({ projects: rows });
|
|
62
|
+
}
|
|
63
|
+
const names = await ctx.client.listProjects();
|
|
64
|
+
return jsonOk({ projects: names, count: names.length });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function handleGetProjectKeys(
|
|
68
|
+
args: Record<string, unknown> | undefined,
|
|
69
|
+
): Promise<ToolTextResult> {
|
|
70
|
+
const project = readString(args, 'project');
|
|
71
|
+
if (!project) return jsonError('missing required arg: project');
|
|
72
|
+
const ctx = await loadAuthContext();
|
|
73
|
+
if (!ctx) {
|
|
74
|
+
return notLoggedIn();
|
|
75
|
+
}
|
|
76
|
+
const env = await readPlain(ctx, project);
|
|
77
|
+
if (!env) {
|
|
78
|
+
return jsonError(`envelope '${project}' not found`);
|
|
79
|
+
}
|
|
80
|
+
const keys = Object.entries(env)
|
|
81
|
+
.filter(([, v]) => typeof v === 'string' && v.length > 0)
|
|
82
|
+
.map(([k]) => k)
|
|
83
|
+
.sort();
|
|
84
|
+
return jsonOk({ project, keys, count: keys.length });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function handleShowManifest(args: Record<string, unknown> | undefined): ToolTextResult {
|
|
88
|
+
const at = readString(args, 'cwd') ?? process.cwd();
|
|
89
|
+
const worker = readString(args, 'worker');
|
|
90
|
+
const inferred = inferAdoptContext(at);
|
|
91
|
+
const picked = pickWorker(inferred.workers, worker);
|
|
92
|
+
if ('error' in picked) {
|
|
93
|
+
return jsonError(picked.error);
|
|
94
|
+
}
|
|
95
|
+
const loaded = loadManifest({ workerCwd: picked.cwd });
|
|
96
|
+
if (loaded.error) {
|
|
97
|
+
return jsonError(`manifest invalid (${loaded.path}): ${loaded.error}`);
|
|
98
|
+
}
|
|
99
|
+
if (!loaded.manifest) {
|
|
100
|
+
return jsonError(
|
|
101
|
+
`no manifest at ${loaded.path}. create via: athsra manifest init --keys=K1,K2`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return jsonOk({
|
|
105
|
+
worker: picked.name,
|
|
106
|
+
manifestPath: loaded.path,
|
|
107
|
+
secrets: loaded.manifest.secrets,
|
|
108
|
+
count: loaded.manifest.secrets.length,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function handleValidateManifest(
|
|
113
|
+
args: Record<string, unknown> | undefined,
|
|
114
|
+
): Promise<ToolTextResult> {
|
|
115
|
+
const project = readString(args, 'project');
|
|
116
|
+
if (!project) return jsonError('missing required arg: project');
|
|
117
|
+
const ctx = await loadAuthContext();
|
|
118
|
+
if (!ctx) {
|
|
119
|
+
return notLoggedIn();
|
|
120
|
+
}
|
|
121
|
+
const at = readString(args, 'cwd') ?? process.cwd();
|
|
122
|
+
const worker = readString(args, 'worker');
|
|
123
|
+
const inferred = inferAdoptContext(at);
|
|
124
|
+
const picked = pickWorker(inferred.workers, worker);
|
|
125
|
+
if ('error' in picked) {
|
|
126
|
+
return jsonError(picked.error);
|
|
127
|
+
}
|
|
128
|
+
const loaded = loadManifest({ workerCwd: picked.cwd });
|
|
129
|
+
if (loaded.error) {
|
|
130
|
+
return jsonError(`manifest invalid (${loaded.path}): ${loaded.error}`);
|
|
131
|
+
}
|
|
132
|
+
if (!loaded.manifest) {
|
|
133
|
+
return jsonError(`no manifest at ${loaded.path}`);
|
|
134
|
+
}
|
|
135
|
+
const env = await readPlain(ctx, project);
|
|
136
|
+
if (!env) {
|
|
137
|
+
return jsonError(`envelope '${project}' not found`);
|
|
138
|
+
}
|
|
139
|
+
const envelopeKeys = Object.entries(env)
|
|
140
|
+
.filter(([, v]) => typeof v === 'string' && v.length > 0)
|
|
141
|
+
.map(([k]) => k);
|
|
142
|
+
const applied = applyManifest(loaded.manifest, envelopeKeys);
|
|
143
|
+
return jsonOk({
|
|
144
|
+
worker: picked.name,
|
|
145
|
+
project,
|
|
146
|
+
manifestPath: loaded.path,
|
|
147
|
+
allowed: applied.allowed,
|
|
148
|
+
missing: applied.missing,
|
|
149
|
+
excluded: applied.excluded,
|
|
150
|
+
counts: {
|
|
151
|
+
allowed: applied.allowed.length,
|
|
152
|
+
missing: applied.missing.length,
|
|
153
|
+
excluded: applied.excluded.length,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools/result.ts — MCP tool 결과 타입 + 헬퍼.
|
|
3
|
+
*
|
|
4
|
+
* `commands/mcp.ts` 에서 분리 (2026-06-02 리팩토링). 동작 보존.
|
|
5
|
+
*
|
|
6
|
+
* MCP `CallToolResult` 의 subset — content + isError. SDK 의 zod-derived 타입을
|
|
7
|
+
* 직접 import 하면 tsc inference OOM (SIGABRT) 가 발생하므로 manual 정의.
|
|
8
|
+
* MCP spec 2025-06-18 의 CallToolResult 와 호환.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ToolTextResult {
|
|
12
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
13
|
+
isError?: boolean;
|
|
14
|
+
structuredContent?: Record<string, unknown>;
|
|
15
|
+
_meta?: Record<string, unknown>;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function jsonOk(data: unknown): ToolTextResult {
|
|
20
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function jsonError(message: string): ToolTextResult {
|
|
24
|
+
return { isError: true, content: [{ type: 'text', text: `error: ${message}` }] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 미인증 시 AI 가 곧바로 행동할 수 있는 안내. `athsra login --sso` 가 브라우저를 열어 modfolio
|
|
29
|
+
* Connect 로 가입/로그인 → 토큰/master pw 를 OS keyring 에 저장한다. AI-온보딩의 인증 부트스트랩 신호.
|
|
30
|
+
*/
|
|
31
|
+
export function notLoggedIn(): ToolTextResult {
|
|
32
|
+
return jsonError(
|
|
33
|
+
'not authenticated on this machine. Run `athsra login --sso` in a terminal — a browser opens ' +
|
|
34
|
+
'to sign up or log in (modfolio Connect SSO). Then retry this tool. ' +
|
|
35
|
+
'(headless/CI: set ATHSRA_TOKEN=ats_… service token instead.)',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 401 / 세션 만료(idle timeout) / unauthorized 류 에러인지. 토큰은 keyring 에 있어도 서버 세션이
|
|
41
|
+
* 만료되면 호출이 401 → 이 경우도 재로그인 안내(notLoggedIn)로 전환해 AI 가 막히지 않게 한다.
|
|
42
|
+
*/
|
|
43
|
+
export function isAuthError(err: unknown): boolean {
|
|
44
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45
|
+
return /\b401\b|unauthorized|session_idle/i.test(msg);
|
|
46
|
+
}
|