ssos-user-cli 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 ADDED
@@ -0,0 +1,47 @@
1
+ # ssos-user-cli
2
+
3
+ 创业OS(SSOS)的官方命令行工具。它使用浏览器授权登录,不在终端接收或保存邮箱密码;登录后可以从终端发现并调用当前工作区允许的 AI 业务工具。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install --global ssos-user-cli
9
+ ```
10
+
11
+ 需要 Node.js 20 或更高版本。安装完成后,命令名是 `ssos`。
12
+
13
+ ## 登录
14
+
15
+ ```bash
16
+ ssos login
17
+ ```
18
+
19
+ 浏览器会打开 SSOS 登录页。用邮箱和密码登录后,按工作区名称选择要使用的工作区并授权。授权完成后回到终端即可;刷新会话保存在本机系统钥匙串中。
20
+
21
+ ## 查看和调用工具
22
+
23
+ ```bash
24
+ ssos tools
25
+ ssos query_bank_transactions --input '{"limit":10}'
26
+ ssos query_vat_invoices --input '{"limit":10}'
27
+ ```
28
+
29
+ 先运行 `ssos tools` 查看当前工作区实际可用的工具和字段,再调用具体工具。工具权限由工作区和账号决定,CLI 不会绕过权限。
30
+
31
+ ## 安全说明
32
+
33
+ - 邮箱密码只在 SSOS 浏览器登录页输入,不要写入命令行、脚本或环境变量。
34
+ - CLI 使用 OAuth 2.0 + PKCE;访问令牌不会作为命令参数传递。
35
+ - 关闭或撤销工作区授权后,再次使用请运行 `ssos login`。
36
+ - 银行流水、发票和税务资料的最终确认仍由用户在 SSOS 中完成。
37
+
38
+ ## 常用命令
39
+
40
+ | 命令 | 用途 |
41
+ | --- | --- |
42
+ | `ssos login` | 登录并选择工作区 |
43
+ | `ssos tools` | 查看当前工作区可用工具 |
44
+ | `ssos query_bank_transactions --input '<JSON>'` | 查询银行流水 |
45
+ | `ssos query_vat_invoices --input '<JSON>'` | 查询增值税发票 |
46
+
47
+ 更多面向用户的操作说明见 [创业OS 帮助中心](https://docs.finlaw.cloud/guides/ssos-cli.html)。
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "ssos-user-cli",
3
+ "version": "0.2.0",
4
+ "description": "Official SSOS command-line client for secure workspace access and AI-assisted business tools",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Xaiver03/ssos.git",
9
+ "directory": "tools/user-cli"
10
+ },
11
+ "homepage": "https://finlaw.cloud",
12
+ "bugs": {
13
+ "url": "https://github.com/Xaiver03/ssos/issues"
14
+ },
15
+ "keywords": [
16
+ "ssos",
17
+ "startup-os",
18
+ "accounting",
19
+ "finance",
20
+ "ai-tools",
21
+ "cli"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "keytar": "^7.9.0"
28
+ },
29
+ "bin": {
30
+ "ssos": "src/index.mjs"
31
+ },
32
+ "files": [
33
+ "src/",
34
+ "README.md"
35
+ ],
36
+ "scripts": {
37
+ "build": "node --check src/index.mjs",
38
+ "test": "node --test test/*.test.mjs",
39
+ "pack:check": "npm pack --dry-run",
40
+ "prepublishOnly": "npm test && npm run build"
41
+ },
42
+ "engines": {
43
+ "node": ">=20.0.0"
44
+ }
45
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,205 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
2
+ import http from 'node:http'
3
+ import { URL } from 'node:url'
4
+
5
+ const KEYCHAIN_SERVICE = 'ssos-user-cli'
6
+ const DEFAULT_REDIRECT_PORT = 8888
7
+ const FORBIDDEN_ENV = ['SSOS_EMAIL', 'SSOS_PASSWORD', 'SSOS_API_KEY', 'SSOS_ACCESS_TOKEN', 'STARTUPOS_PASSWORD', 'STARTUPOS_API_KEY', 'STARTUPOS_ACCESS_TOKEN']
8
+
9
+ function isUuid(value) {
10
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
11
+ }
12
+
13
+ export function createUserCliConfig(env = process.env) {
14
+ if (FORBIDDEN_ENV.some(key => typeof env[key] === 'string' && env[key].trim())) {
15
+ throw new Error('User CLI is OAuth-only; password, API key and access-token environment variables are forbidden')
16
+ }
17
+ const workspaceId = env.SSOS_WORKSPACE_ID?.trim() || undefined
18
+ if (workspaceId && !isUuid(workspaceId)) throw new Error('SSOS_WORKSPACE_ID must be a UUID when supplied')
19
+ const requestedScopes = (env.SSOS_PERMISSION_SCOPES ?? 'accounts:view ai:use contracts:view entries:view entries:create')
20
+ .split(/\s+/).filter(Boolean)
21
+ if (requestedScopes.length === 0 || requestedScopes.some(scope => scope === 'read' || scope === 'write')) {
22
+ throw new Error('User CLI requires permission-point OAuth scopes')
23
+ }
24
+ return Object.freeze({
25
+ apiBaseUrl: env.SSOS_API_URL ?? 'https://api.finlaw.cloud',
26
+ appBaseUrl: env.SSOS_APP_URL ?? 'https://app.finlaw.cloud',
27
+ clientId: env.SSOS_OAUTH_CLIENT_ID ?? 'ssos-mcp-cli',
28
+ workspaceId,
29
+ deviceId: env.SSOS_DEVICE_ID && isUuid(env.SSOS_DEVICE_ID) ? env.SSOS_DEVICE_ID : undefined,
30
+ authorizationChannel: 'user_cli',
31
+ requestedScopes,
32
+ redirectPort: Number(env.SSOS_OAUTH_REDIRECT_PORT ?? DEFAULT_REDIRECT_PORT),
33
+ })
34
+ }
35
+
36
+ export function buildUserCliAuthorizationUrl(config) {
37
+ const url = new URL(`${config.appBaseUrl}/oauth/authorize`)
38
+ url.searchParams.set('client_id', config.clientId)
39
+ url.searchParams.set('redirect_uri', config.redirectUri)
40
+ url.searchParams.set('response_type', 'code')
41
+ url.searchParams.set('state', config.state)
42
+ url.searchParams.set('code_challenge', config.challenge)
43
+ url.searchParams.set('code_challenge_method', 'S256')
44
+ url.searchParams.set('scope', config.requestedScopes.join(' '))
45
+ if (config.workspaceId) url.searchParams.set('workspace_id', config.workspaceId)
46
+ url.searchParams.set('device_id', config.deviceId)
47
+ url.searchParams.set('authorization_channel', 'user_cli')
48
+ return url
49
+ }
50
+
51
+ function defaultKeychain() {
52
+ // Keep the local-agent sidecar binary free of native keychain dependencies.
53
+ // The OAuth cloud command loads keytar lazily only when it is actually used.
54
+ const keychainModule = ['key', 'tar'].join('')
55
+ return import(keychainModule).then(module => module.default ?? module)
56
+ }
57
+
58
+ export function createUserCliAuth(options = {}) {
59
+ const env = options.env ?? process.env
60
+ const config = createUserCliConfig(env)
61
+ const fetchImpl = options.fetchImpl ?? fetch
62
+ const clock = options.clock ?? Date.now
63
+ const keychainPromise = options.keychain ? Promise.resolve(options.keychain) : defaultKeychain()
64
+ let currentAuth = null
65
+ let accessToken = null
66
+ let expiresAt = 0
67
+
68
+ async function deviceId() {
69
+ if (config.deviceId) return config.deviceId
70
+ const keychain = await keychainPromise
71
+ const stored = await keychain.getPassword(KEYCHAIN_SERVICE, 'device-id')
72
+ if (stored && isUuid(stored)) return stored
73
+ const generated = randomUUID()
74
+ await keychain.setPassword(KEYCHAIN_SERVICE, 'device-id', generated)
75
+ return generated
76
+ }
77
+
78
+ async function persist(auth) {
79
+ const keychain = await keychainPromise
80
+ const { accessToken: _accessToken, ...refreshOnly } = auth
81
+ void _accessToken
82
+ const account = config.workspaceId ? `${auth.clientId}:${auth.workspaceId}` : `${auth.clientId}:selected`
83
+ await keychain.setPassword(KEYCHAIN_SERVICE, account, JSON.stringify(refreshOnly))
84
+ }
85
+
86
+ async function refresh() {
87
+ if (!currentAuth) return false
88
+ const response = await fetchImpl(`${config.apiBaseUrl}/api/auth/refresh`, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ refresh_token: currentAuth.refreshToken }),
92
+ })
93
+ if (!response.ok) return false
94
+ const data = await response.json()
95
+ const nextAccessToken = data.accessToken ?? data.access_token
96
+ const nextRefreshToken = data.refreshToken ?? data.refresh_token
97
+ if (typeof nextAccessToken !== 'string' || typeof nextRefreshToken !== 'string') return false
98
+ accessToken = nextAccessToken
99
+ expiresAt = clock() + (Number(data.expires_in ?? 900) * 1000)
100
+ currentAuth = { ...currentAuth, refreshToken: nextRefreshToken, expiresAt, lastUsedAt: clock() }
101
+ await persist(currentAuth)
102
+ return true
103
+ }
104
+
105
+ async function waitForAuthorization() {
106
+ const device = await deviceId()
107
+ const verifier = randomBytes(32).toString('base64url')
108
+ const challenge = createHash('sha256').update(verifier).digest('base64url')
109
+ const state = randomBytes(16).toString('base64url')
110
+ const redirectUri = `http://127.0.0.1:${config.redirectPort}/callback`
111
+ const authorizationUrl = buildUserCliAuthorizationUrl({ ...config, deviceId: device, redirectUri, state, challenge })
112
+ if (options.onAuthorizationUrl) options.onAuthorizationUrl(String(authorizationUrl))
113
+ else console.error(`Open this URL to authorize SSOS User CLI:\n${authorizationUrl}`)
114
+ const code = await new Promise((resolve, reject) => {
115
+ const server = http.createServer((request, response) => {
116
+ const url = new URL(request.url ?? '/', redirectUri)
117
+ if (url.pathname !== '/callback') return response.end('Not found')
118
+ const error = url.searchParams.get('error')
119
+ const codeValue = url.searchParams.get('code')
120
+ if (error || !codeValue || url.searchParams.get('state') !== state) {
121
+ response.writeHead(400); response.end('OAuth authorization failed'); server.close(); reject(new Error(error ?? 'OAuth state or code is invalid')); return
122
+ }
123
+ response.writeHead(200); response.end('SSOS authorization complete; you may close this window.'); server.close(); resolve(codeValue)
124
+ })
125
+ server.listen(config.redirectPort, '127.0.0.1')
126
+ const timeout = setTimeout(() => { server.close(); reject(new Error('OAuth authorization timed out')) }, 5 * 60 * 1000)
127
+ timeout.unref()
128
+ })
129
+ const response = await fetchImpl(`${config.apiBaseUrl}/oauth/token`, {
130
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: config.clientId, code_verifier: verifier }),
132
+ })
133
+ if (!response.ok) throw new Error(`OAuth token exchange failed (${response.status})`)
134
+ const token = await response.json()
135
+ if (!token.access_token || !token.refresh_token || !token.user_id || !token.email) throw new Error('OAuth token response is incomplete')
136
+ accessToken = token.access_token
137
+ expiresAt = clock() + (Number(token.expires_in ?? 900) * 1000)
138
+ const selectedWorkspaceId = token.workspace_id ?? config.workspaceId
139
+ if (!isUuid(selectedWorkspaceId)) throw new Error('OAuth authorization did not return a selected workspace')
140
+ currentAuth = { method: 'oauth', userId: token.user_id, email: token.email, workspaceId: selectedWorkspaceId, workspaceName: token.workspace_name ?? undefined, clientId: config.clientId, deviceId: device, authorizationChannel: 'user_cli', scopes: token.scope?.split(/\s+/).filter(Boolean) ?? [...config.requestedScopes], refreshToken: token.refresh_token, expiresAt, savedAt: clock() }
141
+ await persist(currentAuth)
142
+ }
143
+
144
+ async function initialize() {
145
+ const keychain = await keychainPromise
146
+ const device = await deviceId()
147
+ const accountKey = config.workspaceId ? `${config.clientId}:${config.workspaceId}` : `${config.clientId}:selected`
148
+ const savedRaw = await keychain.getPassword(KEYCHAIN_SERVICE, accountKey)
149
+ if (savedRaw) {
150
+ const saved = JSON.parse(savedRaw)
151
+ if (saved.method === 'oauth' && saved.authorizationChannel === 'user_cli' && saved.deviceId === device) currentAuth = saved
152
+ }
153
+ if (!await refresh()) await waitForAuthorization()
154
+ }
155
+
156
+ async function ensureValidToken() {
157
+ if (!accessToken || clock() >= expiresAt - 60_000) {
158
+ if (!await refresh()) throw new Error('User CLI OAuth session is unavailable; run login again')
159
+ }
160
+ return accessToken
161
+ }
162
+
163
+ async function request(path, init = {}) {
164
+ const token = await ensureValidToken()
165
+ const workspaceId = currentAuth?.workspaceId ?? config.workspaceId
166
+ if (!workspaceId) throw new Error('No workspace selected; run ssos login again')
167
+ const response = await fetchImpl(`${config.apiBaseUrl}${path}`, {
168
+ ...init,
169
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'x-workspace-id': workspaceId, ...(init.headers ?? {}) },
170
+ })
171
+ if (!response.ok) throw new Error(`Cloud CLI request denied (${response.status})`)
172
+ return response.json()
173
+ }
174
+
175
+ return {
176
+ config,
177
+ initialize,
178
+ getAccessToken: async () => ensureValidToken(),
179
+ listTools: () => request('/api/local-agent/tools'),
180
+ callTool: (name, input, options = {}) => {
181
+ if (options.idempotencyKey !== undefined && !isUuid(options.idempotencyKey)) {
182
+ throw new Error('idempotencyKey must be a UUID')
183
+ }
184
+ return request(`/api/local-agent/tools/${encodeURIComponent(name)}/invoke`, {
185
+ method: 'POST',
186
+ body: JSON.stringify({
187
+ arguments: input,
188
+ ...(options.idempotencyKey ? { idempotency_key: options.idempotencyKey } : {}),
189
+ }),
190
+ })
191
+ },
192
+ confirmActionPlan: (planId) => {
193
+ if (!isUuid(planId)) throw new Error('Action Plan id must be a UUID')
194
+ return request(`/api/local-agent/action-plans/${encodeURIComponent(planId)}/confirm`, {
195
+ method: 'POST', body: '{}',
196
+ })
197
+ },
198
+ cancelActionPlan: (planId) => {
199
+ if (!isUuid(planId)) throw new Error('Action Plan id must be a UUID')
200
+ return request(`/api/local-agent/action-plans/${encodeURIComponent(planId)}/cancel`, {
201
+ method: 'POST', body: '{}',
202
+ })
203
+ },
204
+ }
205
+ }
@@ -0,0 +1,41 @@
1
+ const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/
2
+
3
+ function assertToolDefinition(tool, index) {
4
+ if (!tool || typeof tool !== 'object') {
5
+ throw new Error(`Canonical tool ${index} is invalid`)
6
+ }
7
+ if (typeof tool.name !== 'string' || !TOOL_NAME_PATTERN.test(tool.name)) {
8
+ throw new Error(`Canonical tool ${index} has an invalid name`)
9
+ }
10
+ if (tool.description !== undefined && typeof tool.description !== 'string') {
11
+ throw new Error(`Canonical tool ${tool.name} has an invalid description`)
12
+ }
13
+ if (tool.input_schema !== undefined && (typeof tool.input_schema !== 'object' || tool.input_schema === null)) {
14
+ throw new Error(`Canonical tool ${tool.name} has an invalid input schema`)
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Project the cloud canonical Manifest into a local command index.
20
+ *
21
+ * This projection intentionally drops permission/role claims. The local CLI
22
+ * may display a command and validate its input shape, but only the cloud
23
+ * user_cli boundary can authorize or invoke it.
24
+ */
25
+ export function projectCanonicalCommands(tools) {
26
+ if (!Array.isArray(tools)) throw new Error('Canonical discovery must return a tools array')
27
+
28
+ return Object.freeze(tools.map((tool, index) => {
29
+ assertToolDefinition(tool, index)
30
+ return Object.freeze({
31
+ name: tool.name,
32
+ description: tool.description ?? '',
33
+ inputSchema: tool.input_schema ?? {},
34
+ annotations: tool.annotations ?? {},
35
+ })
36
+ }))
37
+ }
38
+
39
+ export function findCanonicalCommand(commands, name) {
40
+ return commands.find(command => command.name === name)
41
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from 'node:url'
3
+ import { projectCanonicalCommands } from './canonical-manifest.mjs'
4
+ import { findCanonicalCommand } from './canonical-manifest.mjs'
5
+ import { createUserCliAuth } from './auth.mjs'
6
+ import { runLocalAgentCommand } from './local-agent-command.mjs'
7
+ import {
8
+ LOCAL_CLI_COMMANDS,
9
+ findLocalCliCommand,
10
+ parseLocalCliInput,
11
+ projectLocalCliCommands,
12
+ } from './local-command-manifest.mjs'
13
+
14
+ /** Static protocol metadata. Authorization and execution are never decided here. */
15
+ export const USER_CLI_COMMANDS = LOCAL_CLI_COMMANDS
16
+
17
+ export function createUserCliCommandIndex(tools) {
18
+ return projectCanonicalCommands(tools)
19
+ }
20
+
21
+ export function createLocalCliCommandIndex(commandIds = LOCAL_CLI_COMMANDS.map(({ id }) => id)) {
22
+ return projectLocalCliCommands(commandIds)
23
+ }
24
+
25
+ function printHelp(output) {
26
+ output.stdout('SSOS User CLI')
27
+ output.stdout('Cloud commands: ssos <command> --input \'<json>\'')
28
+ output.stdout('Cloud login: ssos login (browser login + named workspace selection)')
29
+ output.stdout('Local Desktop commands: ssos local tools')
30
+ output.stdout('Local Desktop run: ssos local <command_id> --cwd <directory> --input \'<json>\'')
31
+ output.stdout('Local Agent auth: ssos local-agent token')
32
+ output.stdout('Action Plan: ssos action-plan <confirm|cancel> <plan-id>')
33
+ output.stdout('Local Agent business tools: ssos local-agent invoke --tool <id> --input \'<json>\' [--idempotency-key <uuid>]')
34
+ output.stdout('MCP is optional; it is not required for the CLI or Desktop local runner.')
35
+ }
36
+
37
+ function parseFlag(argv, name) {
38
+ const index = argv.indexOf(name)
39
+ return index >= 0 ? argv[index + 1] : undefined
40
+ }
41
+
42
+ async function runLocalCommand(argv, options, output) {
43
+ const commandIds = options.localCommandIds ?? LOCAL_CLI_COMMANDS.map(({ id }) => id)
44
+ const commands = createLocalCliCommandIndex(commandIds)
45
+ if (argv[1] === 'tools' || argv.length === 1) {
46
+ output.stdout(JSON.stringify(commands, null, 2))
47
+ return 0
48
+ }
49
+
50
+ const command = findLocalCliCommand(argv[1])
51
+ if (!command || !commands.some(({ id }) => id === command.id)) {
52
+ output.stderr('Local command unavailable: it is not in the Desktop capability catalog.')
53
+ return 2
54
+ }
55
+ if (typeof options.localRunner !== 'function') {
56
+ output.stderr('Local runner unavailable: run this command from SSOS Desktop or provide a bridge.')
57
+ return 3
58
+ }
59
+
60
+ const cwd = parseFlag(argv, '--cwd')
61
+ if (!cwd) {
62
+ output.stderr('Local command requires --cwd <approved-directory>.')
63
+ return 2
64
+ }
65
+ const result = await options.localRunner({
66
+ commandId: command.id,
67
+ cwd,
68
+ confirmed: options.confirmed === true,
69
+ timeoutMs: options.timeoutMs,
70
+ outputLimitBytes: options.outputLimitBytes,
71
+ input: parseLocalCliInput(parseFlag(argv, '--input')),
72
+ })
73
+ output.stdout(JSON.stringify(result, null, 2))
74
+ return 0
75
+ }
76
+
77
+ export async function runUserCliAsync(argv, options = {}) {
78
+ const output = options.output ?? { stdout: console.log, stderr: console.error }
79
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
80
+ printHelp(output)
81
+ return 0
82
+ }
83
+
84
+ if (argv[0] === 'local') {
85
+ return runLocalCommand(argv, options, output)
86
+ }
87
+
88
+ if (argv[0] === 'local-agent') {
89
+ return runLocalAgentCommand(argv.slice(1), {
90
+ env: options.env,
91
+ fetchImpl: options.fetchImpl,
92
+ client: options.localAgentClient,
93
+ auth: options.auth,
94
+ onAuthorizationUrl: options.onAuthorizationUrl,
95
+ }, output)
96
+ }
97
+
98
+ const auth = options.auth ?? createUserCliAuth()
99
+ await auth.initialize()
100
+
101
+ if (argv[0] === 'login') {
102
+ output.stdout('SSOS 登录完成;工作区已在浏览器授权页按名称选择并保存。')
103
+ return 0
104
+ }
105
+
106
+ if (argv[0] === 'action-plan') {
107
+ const action = argv[1]
108
+ const planId = argv[2]
109
+ if ((action !== 'confirm' && action !== 'cancel') || !planId || argv.length !== 3) {
110
+ output.stderr('Usage: ssos action-plan <confirm|cancel> <plan-id>')
111
+ return 2
112
+ }
113
+ const result = action === 'confirm'
114
+ ? await auth.confirmActionPlan(planId)
115
+ : await auth.cancelActionPlan(planId)
116
+ output.stdout(JSON.stringify(result, null, 2))
117
+ return 0
118
+ }
119
+
120
+ const discovered = await auth.listTools()
121
+ const commands = createUserCliCommandIndex(discovered.tools ?? [])
122
+
123
+ if (argv[0] === 'tools') {
124
+ output.stdout(JSON.stringify(commands, null, 2))
125
+ return 0
126
+ }
127
+
128
+ const command = findCanonicalCommand(commands, argv[0])
129
+ if (!command) {
130
+ output.stderr('Command unavailable: it is not present in the trusted cloud Manifest.')
131
+ return 2
132
+ }
133
+
134
+ const inputIndex = argv.indexOf('--input')
135
+ const input = inputIndex >= 0 ? JSON.parse(argv[inputIndex + 1] ?? '{}') : {}
136
+ const idempotencyKey = parseFlag(argv, '--idempotency-key')
137
+ const result = await auth.callTool(command.name, input, { idempotencyKey })
138
+ output.stdout(JSON.stringify(result, null, 2))
139
+ return 0
140
+ }
141
+
142
+ export function runUserCli(
143
+ argv,
144
+ output = { stdout: console.log, stderr: console.error },
145
+ ) {
146
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
147
+ printHelp(output)
148
+ return 0
149
+ }
150
+
151
+ output.stderr('Command unavailable: the user CLI is fail-closed during secure channel rollout.')
152
+ return 2
153
+ }
154
+
155
+ const invokedPath = process.argv[1]
156
+ if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
157
+ runUserCliAsync(process.argv.slice(2)).then((code) => { process.exitCode = code }).catch((error) => {
158
+ console.error(error instanceof Error ? error.message : String(error))
159
+ process.exitCode = 1
160
+ })
161
+ }
@@ -0,0 +1,142 @@
1
+ import { URL } from 'node:url'
2
+
3
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
4
+ const MAX_INPUT_BYTES = 64 * 1024
5
+ const MAX_OUTPUT_BYTES = 512 * 1024
6
+
7
+ function isUuid(value) {
8
+ return typeof value === 'string' && UUID_PATTERN.test(value)
9
+ }
10
+
11
+ function parseFlag(argv, name) {
12
+ const index = argv.indexOf(name)
13
+ return index >= 0 ? argv[index + 1] : undefined
14
+ }
15
+
16
+ function parseJsonInput(raw) {
17
+ if (!raw) return {}
18
+ const bytes = Buffer.byteLength(raw, 'utf8')
19
+ if (bytes > MAX_INPUT_BYTES) throw new Error(`Input exceeds ${MAX_INPUT_BYTES} bytes`)
20
+ const value = JSON.parse(raw)
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Input must be a JSON object')
22
+ return value
23
+ }
24
+
25
+ function createTokenClient(env, fetchImpl = fetch) {
26
+ const token = typeof env.SSOS_LOCAL_AGENT_TOKEN === 'string' ? env.SSOS_LOCAL_AGENT_TOKEN.trim() : ''
27
+ const workspaceId = env.SSOS_WORKSPACE_ID ?? ''
28
+ if (!token) throw new Error('SSOS_LOCAL_AGENT_TOKEN is required for local-agent commands')
29
+ if (!isUuid(workspaceId)) throw new Error('SSOS_WORKSPACE_ID must be a UUID')
30
+ const baseUrl = (env.SSOS_API_URL ?? 'https://api.finlaw.cloud').replace(/\/$/, '')
31
+ return async (path, init = {}) => {
32
+ const response = await fetchImpl(new URL(path, `${baseUrl}/`), {
33
+ ...init,
34
+ headers: {
35
+ Accept: 'application/json',
36
+ 'Content-Type': 'application/json',
37
+ Authorization: `Bearer ${token}`,
38
+ 'x-workspace-id': workspaceId,
39
+ ...(init.headers ?? {}),
40
+ },
41
+ })
42
+ const text = await response.text()
43
+ if (Buffer.byteLength(text, 'utf8') > MAX_OUTPUT_BYTES) throw new Error(`Response exceeds ${MAX_OUTPUT_BYTES} bytes`)
44
+ let body = {}
45
+ if (text) {
46
+ try { body = JSON.parse(text) } catch { body = { content: [{ type: 'text', text }] } }
47
+ }
48
+ if (!response.ok) {
49
+ const error = new Error(`Local-agent request denied (${response.status})`)
50
+ error.status = response.status
51
+ error.body = body
52
+ throw error
53
+ }
54
+ return body
55
+ }
56
+ }
57
+
58
+ export function createLocalAgentClient(options = {}) {
59
+ return createTokenClient(options.env ?? process.env, options.fetchImpl ?? fetch)
60
+ }
61
+
62
+ export async function runLocalAgentCommand(argv, options = {}, output = { stdout: console.log, stderr: console.error }) {
63
+ if (argv[0] === 'token') {
64
+ if (argv.length > 1) {
65
+ output.stderr('Usage: ssos local-agent token')
66
+ return 2
67
+ }
68
+ const auth = options.auth ?? (await import('./auth.mjs')).createUserCliAuth({
69
+ env: options.env ?? process.env,
70
+ fetchImpl: options.fetchImpl ?? fetch,
71
+ onAuthorizationUrl: options.onAuthorizationUrl,
72
+ })
73
+ await auth.initialize()
74
+ const token = await auth.getAccessToken()
75
+ output.stdout(JSON.stringify({ access_token: token }))
76
+ return 0
77
+ }
78
+ const client = options.client ?? createLocalAgentClient({ env: options.env ?? process.env, fetchImpl: options.fetchImpl ?? fetch })
79
+ if (argv.length === 0 || argv[0] === 'tools') {
80
+ output.stdout(JSON.stringify(await client('/api/local-agent/tools'), null, 2))
81
+ return 0
82
+ }
83
+ if (argv[0] === 'action-plan') {
84
+ const action = argv[1]
85
+ const planId = argv[2]
86
+ if ((action !== 'confirm' && action !== 'cancel') || !isUuid(planId) || argv.length !== 3) {
87
+ output.stderr('Usage: ssos local-agent action-plan <confirm|cancel> <plan-id>')
88
+ return 2
89
+ }
90
+ try {
91
+ const result = await client(`/api/local-agent/action-plans/${encodeURIComponent(planId)}/${action}`, {
92
+ method: 'POST', body: '{}',
93
+ })
94
+ output.stdout(JSON.stringify(result, null, 2))
95
+ return 0
96
+ } catch (error) {
97
+ const status = Number(error?.status)
98
+ if (status === 401 || status === 403) output.stderr('Local-agent request denied by server policy.')
99
+ else output.stderr(error instanceof Error ? error.message : String(error))
100
+ return status === 401 || status === 403 ? 4 : 5
101
+ }
102
+ }
103
+ if (argv[0] !== 'invoke') {
104
+ output.stderr('Usage: ssos local-agent tools | ssos local-agent invoke --tool <id> --input \'<json>\' [--idempotency-key <uuid>] | ssos local-agent action-plan <confirm|cancel> <plan-id>')
105
+ return 2
106
+ }
107
+ const toolName = parseFlag(argv, '--tool')
108
+ if (!toolName || !/^[a-z][a-z0-9_-]{1,127}$/i.test(toolName)) {
109
+ output.stderr('local-agent invoke requires a valid --tool <id>.')
110
+ return 2
111
+ }
112
+ let input
113
+ try {
114
+ input = parseJsonInput(parseFlag(argv, '--input'))
115
+ } catch (error) {
116
+ output.stderr(error instanceof Error ? error.message : String(error))
117
+ return 2
118
+ }
119
+ const idempotencyKey = parseFlag(argv, '--idempotency-key')
120
+ if (idempotencyKey !== undefined && !isUuid(idempotencyKey)) {
121
+ output.stderr('local-agent invoke requires --idempotency-key to be a UUID.')
122
+ return 2
123
+ }
124
+ try {
125
+ const result = await client(`/api/local-agent/tools/${encodeURIComponent(toolName)}/invoke`, {
126
+ method: 'POST',
127
+ body: JSON.stringify({
128
+ arguments: input,
129
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
130
+ }),
131
+ })
132
+ output.stdout(JSON.stringify(result, null, 2))
133
+ return 0
134
+ } catch (error) {
135
+ const status = Number(error?.status)
136
+ if (status === 401 || status === 403) output.stderr('Local-agent request denied by server policy.')
137
+ else output.stderr(error instanceof Error ? error.message : String(error))
138
+ return status === 401 || status === 403 ? 4 : 5
139
+ }
140
+ }
141
+
142
+ export const LOCAL_AGENT_LIMITS = Object.freeze({ MAX_INPUT_BYTES, MAX_OUTPUT_BYTES })
@@ -0,0 +1,67 @@
1
+ const COMMAND_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/
2
+
3
+ /**
4
+ * The local command catalog is a protocol description, not an authorization
5
+ * table. Execution remains in the Desktop bridge, where the workspace policy
6
+ * and one-time confirmation are enforced by Rust.
7
+ */
8
+ export const LOCAL_CLI_COMMANDS = Object.freeze([
9
+ {
10
+ id: 'git_status',
11
+ description: 'Show the working tree status',
12
+ capability: 'execute',
13
+ inputSchema: { type: 'object', additionalProperties: false },
14
+ },
15
+ {
16
+ id: 'git_diff',
17
+ description: 'Show a compact working tree diff summary',
18
+ capability: 'execute',
19
+ inputSchema: { type: 'object', additionalProperties: false },
20
+ },
21
+ {
22
+ id: 'rg_files',
23
+ description: 'List files in the approved workspace',
24
+ capability: 'execute',
25
+ inputSchema: { type: 'object', additionalProperties: false },
26
+ },
27
+ {
28
+ id: 'npm_test',
29
+ description: 'Run the workspace test command',
30
+ capability: 'execute',
31
+ inputSchema: { type: 'object', additionalProperties: false },
32
+ },
33
+ {
34
+ id: 'npm_build',
35
+ description: 'Run the workspace production build',
36
+ capability: 'execute',
37
+ inputSchema: { type: 'object', additionalProperties: false },
38
+ },
39
+ ].map((command) => Object.freeze(command)))
40
+
41
+ const COMMANDS_BY_ID = new Map(LOCAL_CLI_COMMANDS.map((command) => [command.id, command]))
42
+
43
+ export function projectLocalCliCommands(commandIds) {
44
+ if (!Array.isArray(commandIds)) throw new Error('Local command discovery must return an array')
45
+
46
+ return Object.freeze(commandIds.map((id, index) => {
47
+ if (typeof id !== 'string' || !COMMAND_ID_PATTERN.test(id)) {
48
+ throw new Error(`Local command ${index} has an invalid id`)
49
+ }
50
+ const command = COMMANDS_BY_ID.get(id)
51
+ if (!command) throw new Error(`Local command is not registered: ${id}`)
52
+ return command
53
+ }))
54
+ }
55
+
56
+ export function findLocalCliCommand(commandId) {
57
+ return COMMANDS_BY_ID.get(commandId)
58
+ }
59
+
60
+ export function parseLocalCliInput(raw) {
61
+ if (raw === undefined) return {}
62
+ const input = JSON.parse(raw)
63
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
64
+ throw new Error('Local CLI input must be a JSON object')
65
+ }
66
+ return input
67
+ }