refactor(openapi/cli): split app usage-face from studio-app build-face

Squash of PR #37641 (worktree-fix+app-abstraction-noun).

Introduces two app nouns:
- app: usage face (run/get/describe/resume), dual-subject account + external-SSO
- studio-app: build face (export/import), account-only

Backend: split read routes by subject with token-type-restricted guards;
shared public projection builder (build_app_describe_response); drop
author/tags from describe to prevent cross-tenant identity leak.
CLI: selectAppReader subject dispatch (account vs permitted-external),
AppReader strategy, studio-app export/import, refreshed help/guides.
This commit is contained in:
GareArc 2026-06-21 19:48:22 -07:00
parent c62276d7de
commit 4111751bdf
No known key found for this signature in database
54 changed files with 828 additions and 326 deletions

View File

@ -31,7 +31,7 @@ from controllers.openapi._models import (
AppDslExportQuery,
AppDslExportResponse,
AppDslImportPayload,
AppInfoResponse,
AppInfo,
AppListQuery,
AppListResponse,
AppListRow,
@ -101,7 +101,7 @@ register_response_schema_models(
MessageMetadata,
AppListRow,
AppListResponse,
AppInfoResponse,
AppInfo,
AppDescribeInfo,
AppDescribeResponse,
AppDslExportResponse,

View File

@ -70,16 +70,14 @@ class PermittedExternalAppsListResponse(BaseModel):
data: list[AppListRow]
class AppInfoResponse(BaseModel):
class AppInfo(BaseModel):
id: str
name: str
description: str | None = None
mode: str
author: str | None = None
tags: list[TagItem] = []
class AppDescribeInfo(AppInfoResponse):
class AppDescribeInfo(AppInfo):
updated_at: str | None = None
service_api_enabled: bool
is_agent: bool = False

View File

@ -28,6 +28,7 @@ from core.app.app_config.common.parameters_mapping import get_parameters_from_fe
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.model import AppMode
from services.account_service import TenantService
from services.app_service import AppListParams, AppService
from services.tag_service import TagService
@ -84,6 +85,42 @@ def parameters_payload(app: App) -> dict:
return Parameters.model_validate(parameters).model_dump(mode="json")
def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescribeResponse:
"""Public projection of an app (name / params / input schema) — never internal config."""
want_info = fields is None or "info" in fields
want_params = fields is None or "parameters" in fields
want_schema = fields is None or "input_schema" in fields
info = (
AppDescribeInfo(
id=str(app.id),
name=app.name,
mode=app.mode,
description=app.description,
updated_at=app.updated_at.isoformat() if app.updated_at else None,
service_api_enabled=bool(app.enable_api),
is_agent=app.mode in (AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT),
)
if want_info
else None
)
parameters: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None
if want_params:
try:
parameters = parameters_payload(app)
except AppUnavailableError:
parameters = dict(_EMPTY_PARAMETERS)
if want_schema:
try:
input_schema = build_input_schema(app)
except AppUnavailableError:
input_schema = dict(EMPTY_INPUT_SCHEMA)
return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema)
@openapi_ns.route("/apps/<string:app_id>/describe")
class AppDescribeApi(AppReadResource):
@auth_router.guard(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
@ -92,46 +129,7 @@ class AppDescribeApi(AppReadResource):
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
# describe is UUID-only (workspace_id query param dropped in #37212).
app = self._load(app_id)
requested = query.fields
want_info = requested is None or "info" in requested
want_params = requested is None or "parameters" in requested
want_schema = requested is None or "input_schema" in requested
info = (
AppDescribeInfo(
id=str(app.id),
name=app.name,
mode=app.mode,
description=app.description,
tags=[TagItem(name=t.name) for t in app.tags],
author=app.author_name,
updated_at=app.updated_at.isoformat() if app.updated_at else None,
service_api_enabled=bool(app.enable_api),
is_agent=app.mode in ("agent-chat", "advanced-chat"),
)
if want_info
else None
)
parameters: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None
if want_params:
try:
parameters = parameters_payload(app)
except AppUnavailableError:
parameters = dict(_EMPTY_PARAMETERS)
if want_schema:
try:
input_schema = build_input_schema(app)
except AppUnavailableError:
input_schema = dict(EMPTY_INPUT_SCHEMA)
return AppDescribeResponse(
info=info,
parameters=parameters,
input_schema=input_schema,
)
return build_app_describe_response(app, query.fields)
@openapi_ns.route("/apps")

View File

@ -8,14 +8,18 @@ EE blueprint chain so this module is unreachable there.
from __future__ import annotations
from flask_restx import Resource
from werkzeug.exceptions import NotFound
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._models import (
AppDescribeQuery,
AppDescribeResponse,
AppListRow,
PermittedExternalAppsListQuery,
PermittedExternalAppsListResponse,
)
from controllers.openapi.apps import build_app_describe_response
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, Edition
from extensions.ext_database import db
@ -82,3 +86,20 @@ class PermittedExternalAppsListApi(Resource):
data=items,
)
return env
@openapi_ns.route("/permitted-external-apps/<string:app_id>/describe")
class PermittedExternalAppDescribeApi(Resource):
@auth_router.guard(
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}),
edition=frozenset({Edition.EE}),
)
@returns(200, AppDescribeResponse, description="Permitted external app description")
@accepts(query=AppDescribeQuery)
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
# App already loaded and ACL-checked by the external_sso pipeline; project it.
app = auth_data.app
if app is None:
raise NotFound("app not found")
return build_app_describe_response(app, query.fields)

View File

@ -331,6 +331,22 @@ Upload a file to use as an input variable when running the app
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /permitted-external-apps/{app_id}/describe
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| fields | query | | No | string |
| app_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Permitted external app description | **application/json**: [AppDescribeResponse](#appdescriberesponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces
#### Responses
@ -507,14 +523,12 @@ Upload a file to use as an input variable when running the app
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | No |
| description | string | | No |
| id | string | | Yes |
| is_agent | boolean | | No |
| mode | string | | Yes |
| name | string | | Yes |
| service_api_enabled | boolean | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
#### AppDescribeQuery
@ -568,16 +582,14 @@ Request body for POST /workspaces/<workspace_id>/apps/imports.
| yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No |
| yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No |
#### AppInfoResponse
#### AppInfo
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | No |
| description | string | | No |
| id | string | | Yes |
| mode | string | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
#### AppListQuery

View File

@ -0,0 +1,73 @@
from types import SimpleNamespace
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA
from controllers.openapi.apps import _EMPTY_PARAMETERS, build_app_describe_response
from controllers.service_api.app.error import AppUnavailableError
class _FakeApp(SimpleNamespace):
pass
def _app() -> _FakeApp:
from datetime import datetime
return _FakeApp(
id="11111111-1111-1111-1111-111111111111",
name="Demo",
mode="chat",
description="d",
tags=[],
author_name="me",
updated_at=datetime(2026, 1, 1),
enable_api=True,
)
def test_fields_none_returns_all_blocks(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), None)
assert resp.info is not None
assert resp.info.name == "Demo"
assert resp.parameters == {"k": "v"}
assert resp.input_schema == {"s": 1}
def test_fields_subset_limits_blocks(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), ["info"])
assert resp.info is not None
assert resp.parameters is None
assert resp.input_schema is None
def test_info_omits_author_and_tags(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {})
resp = build_app_describe_response(_app(), ["info"])
assert resp.info is not None
# Usage-face describe must not expose creator identity or tags (cross-tenant leak).
assert not hasattr(resp.info, "author")
assert not hasattr(resp.info, "tags")
def test_parameters_fallback_on_app_unavailable(monkeypatch):
def _raise(app):
raise AppUnavailableError()
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", _raise)
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), ["parameters"])
assert resp.parameters == dict(_EMPTY_PARAMETERS)
def test_input_schema_fallback_on_app_unavailable(monkeypatch):
def _raise(app):
raise AppUnavailableError()
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", _raise)
resp = build_app_describe_response(_app(), ["input_schema"])
assert resp.input_schema == dict(EMPTY_INPUT_SCHEMA)

View File

@ -63,23 +63,19 @@ def test_envelope_uses_pep695_generics():
def test_app_info_response_dump_matches_spec():
from controllers.openapi._models import AppInfoResponse
from controllers.openapi._models import AppInfo
obj = AppInfoResponse(
obj = AppInfo(
id="app1",
name="X",
description="d",
mode="chat",
author="alice",
tags=[{"name": "prod"}],
)
assert obj.model_dump(mode="json") == {
"id": "app1",
"name": "X",
"description": "d",
"mode": "chat",
"author": "alice",
"tags": [{"name": "prod"}],
}
@ -91,8 +87,6 @@ def test_app_describe_response_nests_info_and_parameters():
name="X",
mode="chat",
description=None,
tags=[],
author=None,
updated_at="2026-05-05T00:00:00+00:00",
service_api_enabled=True,
)

View File

@ -137,6 +137,17 @@ export const commandTree: CommandTree = {
const verIdx = out.indexOf('Version')
expect(authIdx).toBeLessThan(verIdx)
})
it('quotes hyphenated keys and leaves plain identifier keys unquoted', () => {
const entries: CommandEntry[] = [
{ tokens: ['export', 'app'], identifier: 'ExportApp', importPath: '@/commands/export/app/index' },
{ tokens: ['export', 'studio-app'], identifier: 'ExportStudioApp', importPath: '@/commands/export/studio-app/index' },
]
const out = formatModule(entries, buildTree(entries))
expect(out).toContain(`'studio-app': { command: ExportStudioApp, subcommands: {} },`)
expect(out).toContain(`app: { command: ExportApp, subcommands: {} },`)
expect(out).not.toContain(`'app':`)
})
})
function makeFixture(): string {

View File

@ -141,13 +141,24 @@ function emitNode(node: TreeNode, indent: string): string {
return parts.join('\n')
}
function needsQuoting(key: string): boolean {
// A bare object key must be a valid JS identifier: the start class excludes digits
// (letter/_/$ only), so a leading digit fails the match and the key gets quoted.
return !/^[A-Z_$][\w$]*$/i.test(key)
}
function emitKey(key: string): string {
return needsQuoting(key) ? `'${key}'` : key
}
function emitEntry(key: string, node: TreeNode, indent: string): string {
const k = emitKey(key)
const isLeaf = node.subcommands.size === 0 && node.command !== undefined
if (isLeaf)
return `${indent}${key}: { command: ${node.command}, subcommands: {} },`
return `${indent}${k}: { command: ${node.command}, subcommands: {} },`
return [
`${indent}${key}: {`,
`${indent}${k}: {`,
emitNode(node, indent),
`${indent}},`,
].join('\n')

View File

@ -1,17 +1,17 @@
import type { AppsClient } from './apps'
import type { AppReader } from './app-reader'
import type { AppInfoCache } from '@/cache/app-info'
import type { AppMeta, AppMetaFieldKey } from '@/types/app-meta'
import { covers, fromDescribe, mergeMeta } from '@/types/app-meta'
export type AppMetaClientOptions = {
readonly apps: AppsClient
readonly apps: AppReader
readonly host: string
readonly cache?: AppInfoCache
readonly now?: () => Date
}
export class AppMetaClient {
private readonly apps: AppsClient
private readonly apps: AppReader
private readonly host: string
private readonly cache: AppInfoCache | undefined
private readonly now: () => Date

View File

@ -0,0 +1,30 @@
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import { describe, expect, it } from 'vitest'
import { selectAppReader, SubjectKind, subjectOf } from './app-reader'
import { AppsClient } from './apps'
import { PermittedExternalAppsClient } from './permitted-external-apps'
const http = { baseURL: 'https://x', request: async () => new Response() } as unknown as HttpClient
function ctx(external: boolean): ActiveContext {
return {
host: 'h',
email: 'e',
ctx: {
account: { id: 'a', email: 'e', name: 'n' },
external_subject: external ? { email: 'e', issuer: 'i' } : undefined,
},
}
}
describe('selectAppReader', () => {
it('account login → AppsClient', () => {
expect(selectAppReader(ctx(false), http)).toBeInstanceOf(AppsClient)
expect(subjectOf(ctx(false))).toBe(SubjectKind.Account)
})
it('external_subject present → PermittedExternalAppsClient', () => {
expect(selectAppReader(ctx(true), http)).toBeInstanceOf(PermittedExternalAppsClient)
expect(subjectOf(ctx(true))).toBe(SubjectKind.External)
})
})

35
cli/src/api/app-reader.ts Normal file
View File

@ -0,0 +1,35 @@
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { ListQuery } from './apps'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import { AppsClient } from './apps'
import { PermittedExternalAppsClient } from './permitted-external-apps'
export type AppReader = {
list: (q: ListQuery) => Promise<AppListResponse>
describe: (appId: string, fields?: readonly string[]) => Promise<AppDescribeResponse>
}
// The auth subject behind an openapi bearer token. Each kind reads apps from its own surface.
export const SubjectKind = {
Account: 'account',
External: 'external',
} as const
export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind]
export function subjectOf(active: ActiveContext): SubjectKindValue {
return active.ctx.external_subject !== undefined ? SubjectKind.External : SubjectKind.Account
}
type AppReaderFactory = (http: HttpClient) => AppReader
// Maps each auth subject to the app reader for its surface.
const APP_READER_BY_SUBJECT: Readonly<Record<SubjectKindValue, AppReaderFactory>> = {
[SubjectKind.Account]: http => new AppsClient(http),
[SubjectKind.External]: http => new PermittedExternalAppsClient(http),
}
export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader {
return APP_READER_BY_SUBJECT[subjectOf(active)](http)
}

View File

@ -1,4 +1,5 @@
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
import type { AppReader } from './app-reader'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'
@ -12,7 +13,12 @@ export type ListQuery = {
readonly tag?: string
}
export class AppsClient {
// An absent or empty mode filter means "any mode" — collapse both to undefined for the query.
export function normalizeMode(mode: AppMode | '' | undefined): AppMode | undefined {
return mode !== undefined && mode !== '' ? mode : undefined
}
export class AppsClient implements AppReader {
private readonly orpc: OpenApiClient
constructor(http: HttpClient) {
@ -25,7 +31,7 @@ export class AppsClient {
workspace_id: q.workspaceId,
page: q.page ?? 1,
limit: q.limit ?? 20,
mode: q.mode !== undefined && q.mode !== '' ? q.mode : undefined,
mode: normalizeMode(q.mode),
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
tag: q.tag !== undefined && q.tag !== '' ? q.tag : undefined,
},

View File

@ -0,0 +1,27 @@
import type { HttpClient } from '@/http/types'
import { describe, expect, it, vi } from 'vitest'
import { PermittedExternalAppsClient } from './permitted-external-apps'
function fakeHttp() {
return { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
}
type WithOrpc = { orpc: unknown }
describe('PermittedExternalAppsClient', () => {
it('list calls permittedExternalApps.get with paging/filter query', async () => {
const c = new PermittedExternalAppsClient(fakeHttp())
const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { describe: { get: vi.fn() } } } }
await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' })
expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } })
})
it('describe calls permittedExternalApps.byAppId.describe.get with app_id + fields', async () => {
const c = new PermittedExternalAppsClient(fakeHttp())
const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null })
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { describe: { get: dget } } } }
await c.describe('app-1', ['info'])
expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } })
})
})

View File

@ -0,0 +1,34 @@
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { AppReader } from './app-reader'
import type { ListQuery } from './apps'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'
import { normalizeMode } from './apps'
export class PermittedExternalAppsClient implements AppReader {
private readonly orpc: OpenApiClient
constructor(http: HttpClient) {
this.orpc = createOpenApiClient(http)
}
// workspaceId/tag are ignored: the external grant is not workspace-scoped.
async list(q: ListQuery): Promise<AppListResponse> {
return this.orpc.permittedExternalApps.get({
query: {
page: q.page ?? 1,
limit: q.limit ?? 20,
mode: normalizeMode(q.mode),
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
},
})
}
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
return this.orpc.permittedExternalApps.byAppId.describe.get({
params: { app_id: appId },
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
})
}
}

View File

@ -21,8 +21,6 @@ function metaInfoOnly(): AppMeta {
name: 'Greeter',
description: '',
mode: 'chat',
author: 'tester',
tags: [],
updated_at: undefined,
service_api_enabled: false,
is_agent: false,

View File

@ -2,7 +2,9 @@ import type { CommandConstructor } from '@/framework/command'
import { describe, expect, it } from 'vitest'
import Login from '@/commands/auth/login/index'
import DescribeApp from '@/commands/describe/app/index'
import ExportStudioApp from '@/commands/export/studio-app/index'
import GetApp from '@/commands/get/app/index'
import ImportStudioApp from '@/commands/import/studio-app/index'
import ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
@ -13,6 +15,8 @@ const GUIDED_COMMANDS: ReadonlyArray<readonly [string, CommandConstructor]> = [
['resume app', ResumeApp],
['describe app', DescribeApp],
['get app', GetApp],
['export studio-app', ExportStudioApp],
['import studio-app', ImportStudioApp],
['auth login', Login],
]

View File

@ -1,4 +1,4 @@
import type { AppDescribeInfo, TagItem } from '@dify/contracts/api/openapi/types.gen'
import type { AppDescribeInfo } from '@dify/contracts/api/openapi/types.gen'
import type { AppMeta } from '@/types/app-meta'
export const APP_DESCRIBE_MODE_KEY = 'app-describe'
@ -28,10 +28,8 @@ export class AppDescribeOutput {
['Name', info.name],
['ID', info.id],
['Mode', info.mode],
['Author', info.author ?? ''],
['Updated', info.updated_at ?? ''],
['Service API', info.service_api_enabled ? 'true' : 'false'],
['Tags', joinTags(info.tags ?? [])],
]
if (info.description !== '' && info.description !== undefined)
rows.push(['Description', info.description ?? ''])
@ -55,12 +53,6 @@ export class AppDescribeOutput {
}
}
function joinTags(tags: readonly TagItem[]): string {
if (tags.length === 0)
return '<none>'
return tags.map(t => t.name).join(',')
}
function alignedRows(rows: readonly [string, string][]): string[] {
const widest = rows.reduce((m, [k]) => Math.max(m, k.length), 0)
return rows.map(([k, v]) => `${`${k}:`.padEnd(widest + 2)}${v}`)

View File

@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadAppInfoCache } from '@/cache/app-info'
import { formatted, stringifyOutput } from '@/framework/output'
import { ENV_CACHE_DIR } from '@/store/dir'
@ -34,6 +34,7 @@ describe('runDescribeApp', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
vi.restoreAllMocks()
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
@ -60,8 +61,6 @@ describe('runDescribeApp', () => {
expect(out).toContain('Mode:')
expect(out).toContain('chat')
expect(out).toContain('Service API:')
expect(out).toContain('Tags:')
expect(out).toContain('demo')
expect(out).toContain('Description:')
expect(out).toContain('Parameters:')
})
@ -115,4 +114,13 @@ describe('runDescribeApp', () => {
},
)).rejects.toThrow()
})
it('external login resolves describe via the permitted-external route', async () => {
const activeExt: ActiveContext = { host: mock.url, email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const out = await runDescribeApp(
{ appId: 'app-1' },
{ active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url },
)
expect(out.payload.info?.id).toBe('app-1')
})
})

View File

@ -3,7 +3,7 @@ import type { AppInfoCache } from '@/cache/app-info'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import { AppMetaClient } from '@/api/app-meta'
import { AppsClient } from '@/api/apps'
import { selectAppReader } from '@/api/app-reader'
import { runWithSpinner } from '@/sys/io/spinner'
import { nullStreams } from '@/sys/io/streams'
import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta'
@ -26,7 +26,7 @@ export type DescribeAppDeps = {
}
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
const apps = new AppsClient(deps.http)
const apps = selectAppReader(deps.active, deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const io = deps.io ?? nullStreams()
const result = await runWithSpinner(

View File

@ -0,0 +1,12 @@
export const agentGuide = `
WHEN TO USE
A studio app is what you build and edit in Studio on the web console,
inside a workspace the app's source definition, not the published app
that 'run app' invokes. Export pulls that definition as YAML to back it
up, diff it, or recreate the app elsewhere with 'import studio-app'. To
run or inspect an app instead, use the 'app' noun.
ERROR RECOVERY
app not found (404) difyctl get app
not logged in (exit 4) difyctl auth login
`

View File

@ -1,16 +1,17 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
import { agentGuide } from './guide'
import { runExportApp } from './run'
export default class ExportApp extends DifyCommand {
static override description = 'Export an app\'s DSL configuration as YAML'
export default class ExportStudioApp extends DifyCommand {
static override description = 'Export a studio app\'s DSL configuration as YAML'
static override examples = [
'<%= config.bin %> export app <app-id>',
'<%= config.bin %> export app <app-id> --output ./my-app.yaml',
'<%= config.bin %> export app <app-id> --include-secret',
'<%= config.bin %> export app <app-id> --workflow-id <workflow-id>',
'<%= config.bin %> export studio-app <app-id>',
'<%= config.bin %> export studio-app <app-id> --output ./my-app.yaml',
'<%= config.bin %> export studio-app <app-id> --include-secret',
'<%= config.bin %> export studio-app <app-id> --workflow-id <workflow-id>',
]
static override args = {
@ -26,7 +27,7 @@ export default class ExportApp extends DifyCommand {
}
async run(argv: string[]) {
const { args, flags } = this.parse(ExportApp, argv)
const { args, flags } = this.parse(ExportStudioApp, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const result = await runExportApp({
appId: args.id,
@ -42,4 +43,8 @@ export default class ExportApp extends DifyCommand {
ctx.io.out.write('\n')
}
}
override agentGuide(): string {
return agentGuide
}
}

View File

@ -35,9 +35,8 @@ export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps):
const io = deps.io ?? nullStreams()
const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
// workspace is needed to satisfy the auth pipeline; resolving it here
// mirrors what other commands do even though the export endpoint does not
// take workspace_id as a query parameter (it loads tenant from app).
// workspace is resolved to satisfy the auth pipeline; the export endpoint itself
// takes no workspace_id query parameter (it loads tenant from the app).
resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const client = dslFactory(deps.http)

View File

@ -1,8 +1,9 @@
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { stringifyOutput, table } from '@/framework/output'
import { AppListOutput } from './handlers.js'
import { runGetApp } from './run.js'
@ -25,6 +26,7 @@ describe('runGetApp', () => {
})
afterEach(async () => {
vi.restoreAllMocks()
await mock.stop()
})
@ -138,4 +140,25 @@ describe('runGetApp', () => {
}
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
})
it('external login lists via permitted-external client without workspace', async () => {
const list = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 1, has_more: false, data: [{ id: 'x', name: 'X', description: null, mode: 'chat', tags: [], updated_at: null, created_by_name: null, workspace_id: 'w', workspace_name: 'W' }] })
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list)
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
const res = await runGetApp({}, { active, http })
expect(list).toHaveBeenCalled()
const firstCallArg = list.mock.calls[0]![0] as { workspaceId: string }
expect(firstCallArg.workspaceId).toBe('')
expect(res.data).toBeDefined()
})
it('--all-workspaces throws UsageInvalidFlag for external logins', async () => {
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient }))
.rejects
.toThrow(/--all-workspaces is not available for external logins/)
})
})

View File

@ -1,9 +1,12 @@
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
import type { AppReader } from '@/api/app-reader'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import { AppsClient } from '@/api/apps'
import { selectAppReader, SubjectKind, subjectOf } from '@/api/app-reader'
import { WorkspacesClient } from '@/api/workspaces'
import { newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit'
import { getEnv } from '@/sys/index'
import { runWithSpinner } from '@/sys/io/spinner'
@ -28,7 +31,6 @@ export type GetAppDeps = {
readonly http: HttpClient
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly appsFactory?: (http: HttpClient) => AppsClient
readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient
}
@ -40,10 +42,10 @@ export type GetAppResult = {
export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<GetAppResult> {
const env = deps.envLookup ?? getEnv
const appsFactory = deps.appsFactory ?? ((h: HttpClient) => new AppsClient(h))
const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h))
const apps = appsFactory(deps.http)
const external = subjectOf(deps.active) === SubjectKind.External
const apps = selectAppReader(deps.active, deps.http)
const pageSize = resolveLimit(opts.limitRaw, env)
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
const label = opts.appId !== undefined && opts.appId !== '' ? 'Fetching app' : 'Fetching apps'
@ -53,15 +55,20 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
{ io, label },
async (): Promise<AppListResponse> => {
if (opts.allWorkspaces === true) {
if (external)
throw newError(ErrorCode.UsageInvalidFlag, '--all-workspaces is not available for external logins')
const ws = wsFactory(deps.http)
return runAllWorkspaces(apps, ws, opts, page, pageSize)
}
if (opts.appId !== undefined && opts.appId !== '') {
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsName = workspaceNameForId(deps.active, wsId)
const wsId = external ? '' : resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsName = external ? '' : workspaceNameForId(deps.active, wsId)
const desc = await apps.describe(opts.appId, ['info'])
return describeToEnvelope(desc, wsId, wsName)
}
if (external) {
return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name })
}
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
return apps.list({
workspaceId: wsId,
@ -102,9 +109,9 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
name: desc.info.name,
description: desc.info.description,
mode: desc.info.mode as AppMode,
tags: desc.info.tags,
tags: [],
updated_at: desc.info.updated_at,
created_by_name: desc.info.author === '' ? undefined : desc.info.author,
created_by_name: undefined,
workspace_id: wsId,
workspace_name: wsName === '' ? undefined : wsName,
}],
@ -118,7 +125,7 @@ function workspaceNameForId(active: ActiveContext, id: string): string {
}
async function runAllWorkspaces(
apps: AppsClient,
apps: AppReader,
ws: WorkspacesClient,
opts: GetAppOptions,
page: number,

View File

@ -0,0 +1,17 @@
export const agentGuide = `
WHEN TO USE
A studio app is what you build and edit in Studio on the web console,
inside a workspace the app's source definition. Import materialises a
DSL YAML into a new (or existing) studio app; pair it with
'export studio-app' to move an app between workspaces or instances. To
run or inspect the result, switch to the 'app' noun.
BEHAVIOUR
A DSL version mismatch is auto-confirmed; no second command needed.
Missing plugin dependencies are listed on stderr install them before
running the app.
ERROR RECOVERY
workspace required difyctl get workspace
not logged in (exit 4) difyctl auth login
`

View File

@ -1,16 +1,17 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Flags } from '@/framework/flags'
import { agentGuide } from './guide'
import { pluginDependencyLabel, runImportApp } from './run'
export default class ImportApp extends DifyCommand {
static override description = 'Import an app from a DSL YAML file or URL'
export default class ImportStudioApp extends DifyCommand {
static override description = 'Import a studio app from a DSL YAML file or URL'
static override examples = [
'<%= config.bin %> import app --from-file ./app.yaml',
'<%= config.bin %> import app --from-file /path/to/app.yaml --name "My App"',
'<%= config.bin %> import app --from-url https://example.com/my-app.yaml',
'<%= config.bin %> import app --from-file ./app.yaml --app-id <existing-app-id>',
'<%= config.bin %> import studio-app --from-file ./app.yaml',
'<%= config.bin %> import studio-app --from-file /path/to/app.yaml --name "My App"',
'<%= config.bin %> import studio-app --from-url https://example.com/my-app.yaml',
'<%= config.bin %> import studio-app --from-file ./app.yaml --app-id <existing-app-id>',
]
static override flags = {
@ -27,7 +28,7 @@ export default class ImportApp extends DifyCommand {
}
async run(argv: string[]) {
const { flags } = this.parse(ImportApp, argv)
const { flags } = this.parse(ImportStudioApp, argv)
if (flags['from-file'] === undefined && flags['from-url'] === undefined)
this.error('one of --from-file or --from-url is required', { exit: 1 })
if (flags['from-file'] !== undefined && flags['from-url'] !== undefined)
@ -57,4 +58,8 @@ export default class ImportApp extends DifyCommand {
ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`)
}
}
override agentGuide(): string {
return agentGuide
}
}

View File

@ -0,0 +1,66 @@
import type { ActiveContext } from '@/auth/hosts'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppRunClient } from '@/api/app-run'
import { AppsClient } from '@/api/apps'
import { PermittedExternalAppsClient } from '@/api/permitted-external-apps'
import { bufferStreams } from '@/sys/io/streams'
import { resumeApp } from './run.js'
const DESCRIBE_RESULT = {
info: { id: 'app-2', name: 'X', mode: 'workflow', description: '', tags: [], author: '', updated_at: null, service_api_enabled: true, is_agent: false },
parameters: null,
input_schema: null,
}
const FORM_RESP = { user_actions: [{ id: 'submit' }] }
function makeExternalActive(): ActiveContext {
return {
host: 'http://localhost',
email: 'sso@x.io',
ctx: {
account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' },
external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' },
},
} as unknown as ActiveContext
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('resumeApp pre-flight subject strategy', () => {
it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => {
const externalDescribe = vi.fn().mockResolvedValue(DESCRIBE_RESULT)
const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe)
const accountSpy = vi.spyOn(AppsClient.prototype, 'describe')
vi.spyOn(AppRunClient.prototype, 'submitHumanInput').mockResolvedValue(undefined as never)
const io = bufferStreams()
const http = {
baseURL: 'http://localhost',
request: vi.fn().mockImplementation((opts: { path: string }) => {
if (typeof opts.path === 'string' && opts.path.includes('form/human_input')) {
return Promise.resolve(FORM_RESP)
}
// reconnect stream — return an async iterable that ends immediately
const iter: AsyncIterable<never> = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true, value: undefined as never }) }) }
return Promise.resolve(iter)
}),
} as unknown as import('@/http/types').HttpClient
try {
await resumeApp(
{ appId: 'app-2', formToken: 'ft-1', workflowRunId: 'wf-run-1', action: 'submit', inputs: {} },
{ active: makeExternalActive(), http, host: 'http://localhost', io },
)
}
catch {
// run may fail after pre-flight due to stream mock; we only check which describe was called
}
expect(externalSpy).toHaveBeenCalled()
expect(accountSpy).not.toHaveBeenCalled()
})
})

View File

@ -4,10 +4,11 @@ import type { RunContext } from '@/commands/run/app/_strategies/index'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import { AppMetaClient } from '@/api/app-meta'
import { selectAppReader } from '@/api/app-reader'
import { AppRunClient } from '@/api/app-run'
import { AppsClient } from '@/api/apps'
import { pickStrategy } from '@/commands/run/app/_strategies/index'
import { RUN_MODES } from '@/commands/run/app/handlers'
import { resolveInputs, TEXT_FORMATS } from '@/commands/run/app/input-flags'
import { processExit } from '@/sys/index'
import { colorEnabled, colorScheme } from '@/sys/io/color'
import { FieldInfo } from '@/types/app-meta'
@ -37,45 +38,8 @@ export type ResumeAppDeps = {
readonly exit?: (code: number) => never
}
const TEXT_FORMATS = new Set(['', 'text'])
async function resolveInputs(
inputsJson: string | undefined,
inputsFile: string | undefined,
directInputs: Readonly<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
if (inputsJson !== undefined && inputsFile !== undefined)
throw new Error('--inputs and --inputs-file are mutually exclusive')
if (inputsJson !== undefined) {
let parsed: unknown
try {
parsed = JSON.parse(inputsJson)
}
catch {
throw new Error('--inputs must be valid JSON')
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('--inputs must be a JSON object')
return parsed as Record<string, unknown>
}
if (inputsFile !== undefined) {
const { readFile } = await import('node:fs/promises')
let parsed: unknown
try {
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
}
catch {
throw new Error('--inputs-file must contain valid JSON')
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('--inputs-file must be a JSON object')
return parsed as Record<string, unknown>
}
return { ...(directInputs ?? {}) }
}
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
const apps = new AppsClient(deps.http)
const apps = selectAppReader(deps.active, deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const m = await meta.get(opts.appId, [FieldInfo])
const mode = m.info?.mode ?? RUN_MODES.Workflow

View File

@ -0,0 +1,42 @@
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
// Output formats that render the run/resume result as plain text rather than JSON/YAML.
export const TEXT_FORMATS = new Set(['', 'text'])
// Shared by `run app` and `resume app`: --inputs (inline JSON) / --inputs-file (JSON file) /
// direct inputs are mutually exclusive ways to supply the run's variable map.
export async function resolveInputs(
inputsJson: string | undefined,
inputsFile: string | undefined,
directInputs: Readonly<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
if (inputsJson !== undefined && inputsFile !== undefined)
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' })
if (inputsJson !== undefined) {
let parsed: unknown
try {
parsed = JSON.parse(inputsJson)
}
catch {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' })
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' })
return parsed as Record<string, unknown>
}
if (inputsFile !== undefined) {
const { readFile } = await import('node:fs/promises')
let parsed: unknown
try {
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
}
catch {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' })
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' })
return parsed as Record<string, unknown>
}
return { ...(directInputs ?? {}) }
}

View File

@ -1,11 +1,12 @@
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadAppInfoCache } from '@/cache/app-info'
import { resumeApp } from '@/commands/resume/app/run'
import { ENV_CACHE_DIR } from '@/store/dir'
@ -381,4 +382,35 @@ describe('runApp', () => {
expect(docInput.transfer_method).toBe('remote_url')
expect(docInput.url).toBe('https://example.com/override.pdf')
})
it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => {
const describeResult = { info: { id: 'app-1', name: 'X', mode: 'chat', description: '', tags: [], author: '', updated_at: null, service_api_enabled: true, is_agent: false }, parameters: null, input_schema: null }
const externalDescribe = vi.fn().mockResolvedValue(describeResult)
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
const { AppsClient } = await import('@/api/apps')
const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe)
const accountSpy = vi.spyOn(AppsClient.prototype, 'describe')
const io = bufferStreams()
const http = { baseURL: mock.url, request: vi.fn().mockResolvedValue({ answer: 'echo: hi', conversation_id: 'conv-1', message_id: 'msg-1', mode: 'chat', metadata: {} }) } as unknown as HttpClient
const activeExt: ActiveContext = {
host: mock.url,
email: 'sso@x.io',
ctx: {
account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' },
external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' },
},
}
try {
await runApp(
{ appId: 'app-1', message: 'hi' },
{ active: activeExt, http, host: mock.url, io },
)
}
catch {
// run may fail due to mocked http; we only care about which describe was called
}
expect(externalSpy).toHaveBeenCalled()
expect(accountSpy).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
})

View File

@ -3,8 +3,8 @@ import type { AppInfoCache } from '@/cache/app-info'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import { AppMetaClient } from '@/api/app-meta'
import { selectAppReader } from '@/api/app-reader'
import { AppRunClient } from '@/api/app-run'
import { AppsClient } from '@/api/apps'
import { FileUploadClient } from '@/api/file-upload'
import { pickStrategy } from '@/commands/run/app/_strategies/index'
import { BaseError, HttpClientError } from '@/errors/base'
@ -13,6 +13,7 @@ import { processExit } from '@/sys/index'
import { FieldInfo } from '@/types/app-meta'
import { resolveFileInputs } from './file-flags.js'
import { RUN_MODES } from './handlers.js'
import { resolveInputs, TEXT_FORMATS } from './input-flags.js'
export type RunAppOptions = {
readonly appId: string
@ -40,45 +41,8 @@ export type RunAppDeps = {
readonly exit?: (code: number) => never
}
const TEXT_FORMATS = new Set(['', 'text'])
async function resolveInputs(
inputsJson: string | undefined,
inputsFile: string | undefined,
directInputs: Readonly<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
if (inputsJson !== undefined && inputsFile !== undefined)
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' })
if (inputsJson !== undefined) {
let parsed: unknown
try {
parsed = JSON.parse(inputsJson)
}
catch {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' })
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' })
return parsed as Record<string, unknown>
}
if (inputsFile !== undefined) {
const { readFile } = await import('node:fs/promises')
let parsed: unknown
try {
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
}
catch {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' })
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' })
return parsed as Record<string, unknown>
}
return { ...(directInputs ?? {}) }
}
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
const apps = new AppsClient(deps.http)
const apps = selectAppReader(deps.active, deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
try {

View File

@ -17,11 +17,11 @@ import CreateMember from '@/commands/create/member/index'
import DeleteMember from '@/commands/delete/member/index'
import DescribeApp from '@/commands/describe/app/index'
import EnvList from '@/commands/env/list/index'
import ExportApp from '@/commands/export/app/index'
import ExportStudioApp from '@/commands/export/studio-app/index'
import GetApp from '@/commands/get/app/index'
import GetMember from '@/commands/get/member/index'
import GetWorkspace from '@/commands/get/workspace/index'
import ImportApp from '@/commands/import/app/index'
import ImportStudioApp from '@/commands/import/studio-app/index'
import ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
import SetMember from '@/commands/set/member/index'
@ -77,7 +77,7 @@ export const commandTree: CommandTree = {
},
export: {
subcommands: {
app: { command: ExportApp, subcommands: {} },
'studio-app': { command: ExportStudioApp, subcommands: {} },
},
},
get: {
@ -89,7 +89,7 @@ export const commandTree: CommandTree = {
},
import: {
subcommands: {
app: { command: ImportApp, subcommands: {} },
'studio-app': { command: ImportStudioApp, subcommands: {} },
},
},
resume: {

View File

@ -22,6 +22,9 @@ const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding
difyctl run app <id> "hello" -o json
Tips:
* Two app nouns: 'studio-app' is what you build and edit in Studio on the
web console inside a workspace (its source definition export or move it);
'app' is a published app you run and inspect.
* 'difyctl auth list' shows your authenticated contexts; 'difyctl use host'
and 'difyctl use account' switch between them.
* Pass --workspace <id> to target a non-default workspace.
@ -74,6 +77,16 @@ OUTPUT
Pass -o json (or -o yaml) on every command the JSON shape is stable and
documented. Without it you get human tables meant for a terminal.
APP vs STUDIO-APP
Two nouns, two faces of the same app:
studio-app what you build and edit in Studio on the web console,
inside a workspace the app's source definition.
app a published app, live and runnable.
Use 'studio-app' to work with the definition you manage on the website
(export it, move it between workspaces or instances); use 'app' to run
and inspect a published one. The COMMANDS list shows the verbs each
noun supports.
DISCOVERY
difyctl help -o json full command tree + this contract, machine-readable
difyctl get app -o json list apps (ids + modes)

View File

@ -72,6 +72,25 @@ describe('classifyResponse — canonical ErrorBody', () => {
})
})
describe('classifyResponse 403', () => {
it('maps 403 to AccessDenied (exit 4 bucket)', async () => {
const req403 = new Request('https://x/openapi/v1/apps/abc/export')
const res403 = new Response(
JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }),
{ status: 403, headers: { 'content-type': 'application/json' } },
)
const err = await classifyResponse(req403, res403)
expect(err.code).toBe(ErrorCode.AccessDenied)
expect(err.message).toBe('unsupported_token_type')
})
it('403 with no parseable ErrorBody falls back to generic denied message', async () => {
const err = await classified(403, 'not json')
expect(err.code).toBe(ErrorCode.AccessDenied)
expect(err.message).toBe('not permitted')
})
})
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
it('non-JSON body yields no serverError, classification by status', async () => {
const err = await classified(502, '<html>bad gateway</html>')

View File

@ -44,9 +44,17 @@ const RATE_LIMITED_CLASS: StatusClass = {
includeRaw: false,
}
const ACCESS_DENIED_CLASS: StatusClass = {
code: ErrorCode.AccessDenied,
fallbackMessage: () => 'not permitted',
includeRaw: false,
}
function statusClass(status: number): StatusClass {
if (status === 401)
return AUTH_EXPIRED_CLASS
if (status === 403)
return ACCESS_DENIED_CLASS
if (status === 429)
return RATE_LIMITED_CLASS
if (status >= 500)

View File

@ -44,10 +44,10 @@ describe('createOpenApiClient error mapping', () => {
}
it('recovers Dify message from a canonical ErrorBody 4xx response', async () => {
const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 })
const caught = await classifiedError(422, { code: 'invalid_param', message: 'no access', status: 422 })
expect(caught.code).toBe(ErrorCode.Server4xxOther)
expect(caught.httpStatus).toBe(403)
expect(caught.httpStatus).toBe(422)
expect(caught.message).toBe('no access')
// Parity with the transport path: the migrated endpoint's error keeps the request
// method/url and the raw body, so formatted errors still print the `request:` line

View File

@ -9,8 +9,6 @@ function describeResp(): AppDescribeResponse {
name: 'Greeter',
description: '',
mode: 'chat',
author: 'tester',
tags: [],
updated_at: undefined,
service_api_enabled: false,
is_agent: false,

View File

@ -519,14 +519,14 @@ async function provisionApps(
async function importAppCli(filePath: string, wsId: string): Promise<string> {
const result = await run(
['import', 'app', '--from-file', filePath, '--workspace', wsId],
['import', 'studio-app', '--from-file', filePath, '--workspace', wsId],
{ configDir, timeout: 60_000 },
)
if (result.exitCode !== 0)
throw new Error(`import app failed (exit ${result.exitCode}): ${result.stderr}`)
throw new Error(`import studio-app failed (exit ${result.exitCode}): ${result.stderr}`)
const match = result.stderr.match(/app ([0-9a-f-]{36})/)
if (!match?.[1])
throw new Error(`import app: could not parse app_id: ${result.stderr}`)
throw new Error(`import studio-app: could not parse app_id: ${result.stderr}`)
return match[1]
}

View File

@ -288,7 +288,7 @@ describe('E2E / agent skill — get app -o json (auth required)', () => {
expect(line.trim()).not.toMatch(/\s/)
})
itWithSso('[P0] [SSO] dfoe_ get app → JSON error envelope (insufficient_scope)', async () => {
itWithSso('[P0] [SSO] dfoe_ get app -o json → permitted-apps list envelope', async () => {
const tc = await withTempConfig()
try {
const { mkdir, writeFile } = await import('node:fs/promises')
@ -296,12 +296,21 @@ describe('E2E / agent skill — get app -o json (auth required)', () => {
await mkdir(tc.configDir, { recursive: true })
await writeFile(
join(tc.configDir, 'hosts.yml'),
`${[`current_host: ${E.host}`, 'token_storage: file', 'tokens:', ` bearer: ${E.ssoToken}`].join('\n')}\n`,
`${[
`current_host: ${E.host}`,
'token_storage: file',
'tokens:',
` bearer: ${E.ssoToken}`,
'external_subject:',
' email: sso@example.com',
' issuer: https://issuer.example.com',
].join('\n')}\n`,
{ mode: 0o600 },
)
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
expect(r.exitCode).not.toBe(0)
assertErrorEnvelope(r)
assertExitCode(r, 0)
const parsed = assertJson<{ data: unknown[] }>(r)
expect(Array.isArray(parsed.data), 'permitted-apps envelope has a data array').toBe(true)
}
finally { await tc.cleanup() }
})

View File

@ -57,6 +57,8 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
})
}
const itWithSso = optionalIt(Boolean(E.ssoToken))
// ── auth whoami — internal user ──────────────────────────────────────────────
it('[P0] internal user auth whoami outputs email', async () => {
@ -123,12 +125,12 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
expect(result.exitCode).not.toBe(0)
})
it('[P0] external user get app returns insufficient_scope error', async () => {
// Spec: external user get app returns insufficient_scope
itWithSso('[P0] external user can list permitted apps via SSO token', async () => {
// External users read apps via the permitted-external surface (no workspace scope).
await withSSOAuth()
const result = await r(['get', 'app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i)
})
it('[P0] external user whoami outputs SSO email', async () => {
@ -138,8 +140,6 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
expect(result.stdout).toContain('sso-user@example.com')
})
const itWithSso = optionalIt(Boolean(E.ssoToken))
itWithSso('[P0] external user can execute run app using SSO token', async () => {
await injectSsoAuth(configDir, {
host: E.host,

View File

@ -67,12 +67,6 @@ describe('E2E / difyctl describe app', () => {
expect(result.stdout).toMatch(/Name:/i)
})
it('[P1] describe output contains Tags field', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Tags:/i)
})
// ── Input schema ──────────────────────────────────────────────────────────
it('[P0] describe output contains Parameters section', async () => {
@ -172,8 +166,9 @@ describe('E2E / difyctl describe app', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user describe app returns insufficient_scope (3.86)', async () => {
// Spec 3.86: dfoe_ token → insufficient_scope, exit non-0.
itWithSso('[P0] external SSO user can describe a permitted app', async () => {
// A dfoe_ token resolves `describe app` via the permitted-external surface
// (not the account /apps surface), so a permitted app describes successfully.
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
@ -191,8 +186,10 @@ describe('E2E / difyctl describe app', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user describe app should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/ID:/i)
expect(result.stdout).toContain(E.chatAppId)
expect(result.stdout).toMatch(/Mode:/i)
}
finally {
await ssoTmp.cleanup()
@ -225,16 +222,6 @@ describe('E2E / difyctl describe app', () => {
expect(result.stdout).toContain('e2e-test')
})
it('[P1] describe output contains Author field (3.67)', async () => {
// Spec 3.67: output includes Author field when app has an author.
const result = await withRetry(
() => fx.r(['describe', 'app', E.chatAppId]),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Author:/i)
})
it('[P0] Inputs section shows parameter names (3.70)', async () => {
// Spec 3.70: Parameters/Inputs section displays variable names.
// workflow app has x, num, enum_var, paragraph.

View File

@ -151,15 +151,15 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app -A returns insufficient_scope error (3.103)', async () => {
// Spec 3.103: dfoe_ token on -A → insufficient_scope, exit non-0.
// Merged from two duplicate fake-token cases; now uses real DIFY_E2E_SSO_TOKEN.
itWithSso('[P0] external SSO user get app -A is rejected as an invalid flag', async () => {
// --all-workspaces is meaningless for external SSO users (no workspace
// scope), so the CLI rejects it client-side with usage_invalid_flag (exit 2).
// Uses real DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
// Use minimal SSO hosts.yml (no workspace) so CLI hits the scope/auth error path.
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
@ -171,8 +171,8 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user -A should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth|missing/i)
assertExitCode(result, 2)
expect(result.stderr).toMatch(/--all-workspaces is not available for external logins/)
}
finally {
await ssoTmp.cleanup()

View File

@ -206,17 +206,15 @@ describe('E2E / difyctl get app (list)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.24 / 3.25)', async () => {
// Spec 3.24: dfoe_ token → insufficient_scope; Spec 3.25: exit code is 1.
itWithSso('[P0] external SSO user can list permitted apps', async () => {
// A dfoe_ token lists apps via the permitted-external surface
// (apps:read:permitted-external scope), with no workspace scoping.
// Uses DIFY_E2E_SSO_TOKEN (itWithSso skips when not configured).
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
// SSO (dfoe_) users have apps:run scope only, not apps:list.
// Inject a minimal hosts.yml without workspace so the CLI reaches the
// scope-check path rather than resolving the workspace successfully.
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
@ -228,8 +226,8 @@ describe('E2E / difyctl get app (list)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i)
}
finally {
await ssoTmp.cleanup()

View File

@ -68,8 +68,9 @@ describe('E2E / difyctl get app <id> (single)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app <id> returns insufficient_scope error (3.55)', async () => {
// Spec 3.55: dfoe_ token on get app <id> → insufficient_scope, exit 1.
itWithSso('[P0] external SSO user can get a permitted app by id', async () => {
// A dfoe_ token resolves get app <id> via the permitted-external describe
// surface (apps:read:permitted-external scope), so a permitted app is returned.
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
@ -87,8 +88,8 @@ describe('E2E / difyctl get app <id> (single)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user get app <id> should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toContain(E.chatAppId)
}
finally {
await ssoTmp.cleanup()

View File

@ -1,5 +1,5 @@
/**
* E2E: difyctl export app DSL export
* E2E: difyctl export studio-app DSL export
*
* Prerequisites (DIFY_E2E_* env vars):
* DIFY_E2E_WORKFLOW_APP_ID echo-workflow app (no model provider dependency)
@ -21,7 +21,7 @@ import { resolveEnv } from '../../setup/env.js'
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
describe('E2E / difyctl export app', () => {
describe('E2E / difyctl export studio-app', () => {
let fx: AuthFixture
beforeEach(async () => {
@ -34,37 +34,37 @@ describe('E2E / difyctl export app', () => {
// ── Basic export ──────────────────────────────────────────────────────────
it('[P0] exported DSL is non-empty YAML printed to stdout', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
})
it('[P0] exported YAML contains kind: app', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
})
it('[P0] exported YAML contains version field', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^version:/m)
})
it('[P0] exported YAML contains app section with mode', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^\s+mode:/m)
})
it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.endsWith('\n')).toBe(true)
})
it('[P1] chat app export also succeeds and includes mode', async () => {
const result = await fx.r(['export', 'app', E.chatAppId])
const result = await fx.r(['export', 'studio-app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
expect(result.stdout).toMatch(/^\s+mode:/m)
@ -76,7 +76,7 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-'))
const outPath = join(dir, 'exported.yaml')
try {
const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath])
const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath])
assertExitCode(result, 0)
const content = await readFile(outPath, 'utf8')
expect(content).toMatch(/^kind:\s*app/m)
@ -92,8 +92,8 @@ describe('E2E / difyctl export app', () => {
const outPath = join(dir, 'exported.yaml')
try {
const [stdoutResult, fileResult] = await Promise.all([
fx.r(['export', 'app', E.workflowAppId]),
fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => {
fx.r(['export', 'studio-app', E.workflowAppId]),
fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]).then(async (r) => {
const content = await readFile(outPath, 'utf8')
return { exitCode: r.exitCode, content }
}),
@ -113,12 +113,12 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-'))
const dslPath = join(dir, 'roundtrip.yaml')
try {
const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath])
const exportResult = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', dslPath])
assertExitCode(exportResult, 0)
const importResult = await fx.r([
'import',
'app',
'studio-app',
'--from-file',
dslPath,
'--name',
@ -137,7 +137,7 @@ describe('E2E / difyctl export app', () => {
// ── Error scenarios ───────────────────────────────────────────────────────
it('[P0] non-existent app returns exit code 1 with error in stderr', async () => {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e'])
const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-export-e2e'])
expect(result.exitCode).toBe(1)
expect(result.stderr.length).toBeGreaterThan(0)
})
@ -145,7 +145,7 @@ describe('E2E / difyctl export app', () => {
it('[P0] unauthenticated export returns auth error (exit code 4)', async () => {
const unauthTmp = await withTempConfig()
try {
const result = await run(['export', 'app', E.workflowAppId], {
const result = await run(['export', 'studio-app', E.workflowAppId], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
@ -156,13 +156,13 @@ describe('E2E / difyctl export app', () => {
})
it('[P1] export with missing app id argument exits non-zero', async () => {
const result = await fx.r(['export', 'app'])
const result = await fx.r(['export', 'studio-app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument|required|app id/i)
})
it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/http_status:\s*4\d\d/)
expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/)
@ -171,7 +171,7 @@ describe('E2E / difyctl export app', () => {
it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => {
const result = await fx.r([
'export',
'app',
'studio-app',
E.workflowAppId,
'--workflow-id',
'00000000-0000-0000-0000-000000000000',
@ -184,7 +184,7 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-'))
const outPath = join(dir, 'should-not-exist.yaml')
try {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
expect(result.exitCode).not.toBe(0)
const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false)
expect(exists, 'output file must not be created on export failure').toBe(false)

View File

@ -82,10 +82,9 @@ describe('E2E / error message standards (spec 5.3)', () => {
// ── 5.63 dfoe_ token insufficient_scope ──────────────────────────────────
itWithSso('[P0] 5.63 dfoe_ SSO token with workspace returns insufficient_scope for management commands', async () => {
// Spec 5.63: an external SSO token (dfoe_) must not be able to access
// internal management APIs; the CLI must return an insufficient_scope
// error with exit 1.
itWithSso('[P0] dfoe_ SSO token is denied account-only management commands', async () => {
// A dfoe_ SSO token is rejected with a non-zero exit when it targets an
// account-only management command (`export studio-app`).
const { mkdir } = await import('node:fs/promises')
const ssoTmp = await withTempConfig()
try {
@ -95,16 +94,13 @@ describe('E2E / error message standards (spec 5.3)', () => {
`token_storage: file`,
`tokens:`,
` bearer: ${E.ssoToken}`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: member`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
const result = await run(['export', 'studio-app', E.chatAppId], { configDir: ssoTmp.configDir })
assertNonZeroExit(result)
// In this environment ssoToken may be a dfoa_ token; the server returns
// either insufficient_scope or server_5xx — both are non-zero exits.
expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0)
}
finally {

View File

@ -269,8 +269,34 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
name: app.name,
description: app.description,
mode: app.mode,
author: app.author ?? '',
tags: app.tags,
updated_at: app.updated_at,
service_api_enabled: app.service_api_enabled ?? false,
is_agent: app.is_agent ?? false,
}
: null,
parameters: wantParams ? (app.parameters ?? null) : null,
input_schema: wantInputSchema ? (app.input_schema ?? null) : null,
})
})
app.get('/openapi/v1/permitted-external-apps/:id/describe', (c) => {
const id = c.req.param('id')
const fieldsRaw = c.req.query('fields') ?? ''
const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '')
// External subjects have no workspace scope; the app is reachable across workspaces.
const app = APPS.find(a => a.id === id)
if (app === undefined)
return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
const wantInfo = fields.length === 0 || fields.includes('info')
const wantParams = fields.length === 0 || fields.includes('parameters')
const wantInputSchema = fields.length === 0 || fields.includes('input_schema')
return c.json({
info: wantInfo
? {
id: app.id,
name: app.name,
description: app.description,
mode: app.mode,
updated_at: app.updated_at,
service_api_enabled: app.service_api_enabled ?? false,
is_agent: app.is_agent ?? false,

View File

@ -30,6 +30,9 @@ import {
zGetHealthResponse,
zGetOauthDeviceLookupQuery,
zGetOauthDeviceLookupResponse,
zGetPermittedExternalAppsByAppIdDescribePath,
zGetPermittedExternalAppsByAppIdDescribeQuery,
zGetPermittedExternalAppsByAppIdDescribeResponse,
zGetPermittedExternalAppsQuery,
zGetPermittedExternalAppsResponse,
zGetVersionResponse,
@ -450,6 +453,30 @@ export const oauth = {
}
export const get12 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getPermittedExternalAppsByAppIdDescribe',
path: '/permitted-external-apps/{app_id}/describe',
tags: ['openapi'],
})
.input(
z.object({
params: zGetPermittedExternalAppsByAppIdDescribePath,
query: zGetPermittedExternalAppsByAppIdDescribeQuery.optional(),
}),
)
.output(zGetPermittedExternalAppsByAppIdDescribeResponse)
export const describe2 = {
get: get12,
}
export const byAppId2 = {
describe: describe2,
}
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -461,7 +488,8 @@ export const get12 = oc
.output(zGetPermittedExternalAppsResponse)
export const permittedExternalApps = {
get: get12,
get: get13,
byAppId: byAppId2,
}
export const post9 = oc
@ -544,7 +572,7 @@ export const byMemberId = {
role,
}
export const get13 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -578,7 +606,7 @@ export const post11 = oc
.output(zPostWorkspacesByWorkspaceIdMembersResponse)
export const members = {
get: get13,
get: get14,
post: post11,
byMemberId,
}
@ -598,7 +626,7 @@ export const switch_ = {
post: post12,
}
export const get14 = oc
export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -610,13 +638,13 @@ export const get14 = oc
.output(zGetWorkspacesByWorkspaceIdResponse)
export const byWorkspaceId = {
get: get14,
get: get15,
apps: apps2,
members,
switch: switch_,
}
export const get15 = oc
export const get16 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -627,7 +655,7 @@ export const get15 = oc
.output(zGetWorkspacesResponse)
export const workspaces = {
get: get15,
get: get16,
byWorkspaceId,
}

View File

@ -20,14 +20,12 @@ export type AccountResponse = {
}
export type AppDescribeInfo = {
author?: string | null
description?: string | null
id: string
is_agent?: boolean
mode: string
name: string
service_api_enabled: boolean
tags?: Array<TagItem>
updated_at?: string | null
}
@ -66,13 +64,11 @@ export type AppDslImportPayload = {
yaml_url?: string | null
}
export type AppInfoResponse = {
author?: string | null
export type AppInfo = {
description?: string | null
id: string
mode: string
name: string
tags?: Array<TagItem>
}
export type AppListQuery = {
@ -945,6 +941,32 @@ export type GetPermittedExternalAppsResponses = {
export type GetPermittedExternalAppsResponse
= GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses]
export type GetPermittedExternalAppsByAppIdDescribeData = {
body?: never
path: {
app_id: string
}
query?: {
fields?: string
}
url: '/permitted-external-apps/{app_id}/describe'
}
export type GetPermittedExternalAppsByAppIdDescribeErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetPermittedExternalAppsByAppIdDescribeError
= GetPermittedExternalAppsByAppIdDescribeErrors[keyof GetPermittedExternalAppsByAppIdDescribeErrors]
export type GetPermittedExternalAppsByAppIdDescribeResponses = {
200: AppDescribeResponse
}
export type GetPermittedExternalAppsByAppIdDescribeResponse
= GetPermittedExternalAppsByAppIdDescribeResponses[keyof GetPermittedExternalAppsByAppIdDescribeResponses]
export type GetWorkspacesData = {
body?: never
path?: never

View File

@ -11,6 +11,19 @@ export const zAccountPayload = z.object({
name: z.string(),
})
/**
* AppDescribeInfo
*/
export const zAppDescribeInfo = z.object({
description: z.string().nullish(),
id: z.string(),
is_agent: z.boolean().optional().default(false),
mode: z.string(),
name: z.string(),
service_api_enabled: z.boolean(),
updated_at: z.string().nullish(),
})
/**
* AppDescribeQuery
*
@ -22,6 +35,15 @@ export const zAppDescribeQuery = z.object({
fields: z.string().optional(),
})
/**
* AppDescribeResponse
*/
export const zAppDescribeResponse = z.object({
info: zAppDescribeInfo.nullish(),
input_schema: z.record(z.string(), z.unknown()).nullish(),
parameters: z.record(z.string(), z.unknown()).nullish(),
})
/**
* AppDslExportQuery
*
@ -58,6 +80,16 @@ export const zAppDslImportPayload = z.object({
yaml_url: z.string().nullish(),
})
/**
* AppInfo
*/
export const zAppInfo = z.object({
description: z.string().nullish(),
id: z.string(),
mode: z.string(),
name: z.string(),
})
/**
* AppMode
*/
@ -465,42 +497,6 @@ export const zTagItem = z.object({
name: z.string(),
})
/**
* AppDescribeInfo
*/
export const zAppDescribeInfo = z.object({
author: z.string().nullish(),
description: z.string().nullish(),
id: z.string(),
is_agent: z.boolean().optional().default(false),
mode: z.string(),
name: z.string(),
service_api_enabled: z.boolean(),
tags: z.array(zTagItem).optional().default([]),
updated_at: z.string().nullish(),
})
/**
* AppDescribeResponse
*/
export const zAppDescribeResponse = z.object({
info: zAppDescribeInfo.nullish(),
input_schema: z.record(z.string(), z.unknown()).nullish(),
parameters: z.record(z.string(), z.unknown()).nullish(),
})
/**
* AppInfoResponse
*/
export const zAppInfoResponse = z.object({
author: z.string().nullish(),
description: z.string().nullish(),
id: z.string(),
mode: z.string(),
name: z.string(),
tags: z.array(zTagItem).optional().default([]),
})
/**
* AppListRow
*/
@ -896,6 +892,19 @@ export const zGetPermittedExternalAppsQuery = z.object({
*/
export const zGetPermittedExternalAppsResponse = zPermittedExternalAppsListResponse
export const zGetPermittedExternalAppsByAppIdDescribePath = z.object({
app_id: z.string(),
})
export const zGetPermittedExternalAppsByAppIdDescribeQuery = z.object({
fields: z.string().optional(),
})
/**
* Permitted external app description
*/
export const zGetPermittedExternalAppsByAppIdDescribeResponse = zAppDescribeResponse
/**
* Workspace list
*/