Skip to content

Commit a18485d

Browse files
committed
feat: add browser-approved CLI login
1 parent 7ca03c0 commit a18485d

25 files changed

Lines changed: 1637 additions & 37 deletions

File tree

DREAMNUM.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
collaboration entry points, and background recovery processes. Contract: [Workspace README](apps/workspace/README.md)
2424
and [OpenAPI source](apps/workspace/contracts/http/openapi.yaml).
2525
- **Univer Workspace CLI** — the internally packaged `univer-workspace-cli` application for agent-driven remote
26-
Workspace authoring, inspection, rendering, exchange, Worktree, and review workflows. Contract:
26+
Workspace authoring, inspection, rendering, exchange, Worktree, and review workflows, with browser-approved
27+
passwordless CLI session handoff for password and external-identity users. Contract:
2728
[CLI README](apps/cli/README.md) and [release workflow](.github/workflows/release-cli.yml).
2829

2930
## Depends on
@@ -63,6 +64,8 @@
6364
retain their identities, metadata, and recovery state.
6465
- Login sessions, password hashes, stable GitHub and Discord user identifiers, ACLs, sharing state, and user-authored
6566
content are protected application data. OAuth access tokens are used only during sign-in and are not persisted.
67+
- Short-lived pending CLI browser authorizations are bounded process-local state; approval issues a separate normal
68+
persisted login session and does not pass a browser cookie, password, or OAuth access token through the agent.
6669
- OAuth secrets, trusted Bot credentials, registry credentials, production licenses, and deployment credentials are
6770
environment or build configuration and must not be committed to the repository.
6871
- Stable `vX.Y.Z` tags are immutable source coordinates shared by the stable CLI release and manually selected

apps/cli/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,38 @@ Workspace deployment, run `univer-workspace-cli config set workspace.origin <ori
7777
agent access to the executable and the intended Workspace; the bundled guidance supplies the
7878
version-correct workflow.
7979

80+
## Login
81+
82+
The default login does not ask the agent for a Workspace password:
83+
84+
```bash
85+
univer-workspace-cli login
86+
```
87+
88+
The command prints a short-lived Workspace URL and verification code, saves the pending request
89+
locally, and exits immediately. The agent must send the URL and code to the user, then stop and wait
90+
for the user's reply—it must not poll while approval is pending. Open that URL in your own browser,
91+
sign in if necessary, and confirm the matching code. Existing browser sessions and all browser
92+
sign-in methods—including GitHub and Discord—work with this flow.
93+
94+
Only after the user confirms approval, complete the one-time exchange:
95+
96+
```bash
97+
univer-workspace-cli login --complete
98+
```
99+
100+
`--complete` checks once and exits; it does not wait or poll. Approval creates a separate CLI session
101+
without copying the browser cookie or an OAuth access token through the agent.
102+
103+
For compatibility, a user at an interactive terminal can still sign in directly with a Workspace
104+
username and password:
105+
106+
```bash
107+
univer-workspace-cli login --username <name>
108+
```
109+
110+
`--password-stdin` remains available for controlled automation that already owns a password secret.
111+
80112
## License policy
81113

82114
The CLI bundles the same application-owned runtime development license as the

apps/cli/skill-data/core/SKILL.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,23 +175,37 @@ is uncertain, start a new Worktree rather than guessing.
175175

176176
## Connect
177177

178-
The origin defaults to `https://workspace.univer.plus/`. Override it only when needed, then login
179-
without putting a password in shell history:
178+
The origin defaults to `https://workspace.univer.plus/`. Override it only when needed, then use the
179+
two-command browser approval protocol below. Never ask the user for a password.
180180

181181
```bash
182182
# Optional override:
183183
univer-workspace-cli config set workspace.origin https://workspace.example.com
184184

185-
# Human at an interactive terminal:
186-
univer-workspace-cli login --username <name>
185+
# Step 1: create an approval request. This command exits immediately.
186+
univer-workspace-cli login --json
187187

188-
# Agent or CI when a secret is already available:
188+
# Step 2: relay verificationUrl and userCode to the user, then STOP.
189+
# Do not poll, do not run --complete, and do not continue the Workspace task.
190+
# Wait until the user explicitly says that they approved the request.
191+
192+
# Step 3: only after that user confirmation, exchange the approval once.
193+
univer-workspace-cli login --complete --json
194+
195+
# Compatibility only, when the caller already owns a password secret:
189196
printf '%s\n' "$WORKSPACE_PASSWORD" | univer-workspace-cli login --username <name> --password-stdin
190197

191198
univer-workspace-cli whoami --json
192199
univer-workspace-cli space list --json
193200
```
194201

202+
The first command returns `status: "authorization_required"`, `verificationUrl`, `userCode`,
203+
`expiresAt`, and `nextCommand`. The completion command also exits immediately: it returns
204+
`status: "authenticated"` on success or `status: "authorization_pending"` if the browser approval
205+
has not finished. If it is still pending, show the same URL/code to the user and wait for their
206+
reply; do not create a polling loop. Password, GitHub, and Discord browser accounts all use this
207+
same handoff.
208+
195209
## Start a new task
196210

197211
### Modify an existing file

apps/cli/src/command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function executeCommand<Result>(
1919
: "";
2020
const hint =
2121
error.code === "workspace-authentication-required"
22-
? "\nHint: run univer-workspace-cli login --username <name>"
22+
? "\nHint: run univer-workspace-cli login"
2323
: "";
2424
command.error(`${error.code}: ${error.message}${detail}${hint}`, {
2525
code: "workspace.command.failed",

apps/cli/src/features/auth/command.ts

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,136 @@ import { workspaceError } from "../../errors.js";
66
import type { WorkspaceAuth } from "./session.js";
77

88
interface LoginOptions extends JsonOption {
9+
readonly complete?: boolean;
910
readonly passwordStdin?: boolean;
10-
readonly username: string;
11+
readonly username?: string;
1112
}
1213

1314
export function createAuthCommands(auth: WorkspaceAuth): readonly Command[] {
1415
const login = new Command("login")
15-
.description("Log in to the configured Workspace")
16-
.requiredOption("--username <name>", "Workspace username")
17-
.option("--password-stdin", "read the password from stdin")
16+
.description("Start or complete a user-approved Workspace browser login")
17+
.option(
18+
"--complete",
19+
"complete the pending browser login after the user confirms approval",
20+
)
21+
.option(
22+
"--username <name>",
23+
"use Workspace username and password instead of browser approval",
24+
)
25+
.option("--password-stdin", "read the password from stdin (requires --username)")
1826
.option("--json", "write structured JSON")
27+
.addHelpText(
28+
"after",
29+
[
30+
"",
31+
"Agent browser-login workflow:",
32+
" 1. Run `univer-workspace-cli login`; it prints an approval URL and exits.",
33+
" 2. Send the URL and verification code to the user, then wait for their reply.",
34+
" 3. Only after the user confirms approval, run `univer-workspace-cli login --complete`.",
35+
" Do not ask the user for a password and do not poll --complete while waiting.",
36+
"",
37+
].join("\n"),
38+
)
1939
.action(async (options: LoginOptions) => {
20-
const result = await executeCommand(login, async () => {
21-
const password = (
22-
await readPassword(options.passwordStdin === true ? "stdin" : "interactive")
23-
).replace(/\r?\n$/u, "");
24-
if (password === "") {
25-
throw workspaceError("workspace-argument-invalid", "Password is empty.");
40+
if (options.username !== undefined) {
41+
const username = options.username;
42+
const result = await executeCommand(login, async () => {
43+
if (options.complete === true) {
44+
throw workspaceError(
45+
"workspace-argument-invalid",
46+
"--complete cannot be combined with --username.",
47+
);
48+
}
49+
const password = (
50+
await readPassword(options.passwordStdin === true ? "stdin" : "interactive")
51+
).replace(/\r?\n$/u, "");
52+
if (password === "") {
53+
throw workspaceError("workspace-argument-invalid", "Password is empty.");
54+
}
55+
return await auth.login({ password, username });
56+
});
57+
present(login, options, result, `Logged in to ${result.origin} as ${result.subject.name}`);
58+
return;
59+
}
60+
61+
if (options.passwordStdin === true) {
62+
await executeCommand(login, async () => {
63+
throw workspaceError(
64+
"workspace-argument-invalid",
65+
"--password-stdin requires --username.",
66+
);
67+
});
68+
return;
69+
}
70+
71+
if (options.complete === true) {
72+
const result = await executeCommand(login, async () => {
73+
const pending = await auth.pendingCliLogin();
74+
if (pending === undefined) {
75+
throw workspaceError(
76+
"workspace-cli-authorization-missing",
77+
"No pending browser login exists. Run login first.",
78+
);
79+
}
80+
const completion = await auth.completeCliLogin(pending);
81+
if (completion.status === "pending") {
82+
return {
83+
status: "authorization_pending" as const,
84+
origin: pending.origin,
85+
userCode: pending.userCode,
86+
verificationUrl: pending.verificationUrl,
87+
};
88+
}
89+
return completion;
90+
});
91+
if (result.status === "authorization_pending") {
92+
present(
93+
login,
94+
options,
95+
result,
96+
[
97+
"Browser approval has not completed.",
98+
"This command has exited and is not waiting.",
99+
"Ask the user to finish the approval, then run login --complete once more.",
100+
].join("\n"),
101+
);
102+
return;
26103
}
27-
return await auth.login({ password, username: options.username });
28-
});
29-
present(login, options, result, `Logged in to ${result.origin} as ${result.subject.name}`);
104+
present(
105+
login,
106+
options,
107+
result,
108+
`Logged in to ${result.origin} as ${result.subject.name}`,
109+
);
110+
return;
111+
}
112+
113+
const pending = await executeCommand(login, async () => await auth.startCliLogin());
114+
const result = {
115+
status: "authorization_required" as const,
116+
origin: pending.origin,
117+
userCode: pending.userCode,
118+
verificationUrl: pending.verificationUrl,
119+
expiresAt: new Date(pending.expiresAt).toISOString(),
120+
nextCommand: "univer-workspace-cli login --complete",
121+
};
122+
present(
123+
login,
124+
options,
125+
result,
126+
[
127+
"Browser approval required.",
128+
"",
129+
"Send this URL and verification code to the user:",
130+
pending.verificationUrl,
131+
`Verification code: ${pending.userCode}`,
132+
"",
133+
"This command has exited and is not waiting.",
134+
"Wait for the user to confirm approval. Do not poll in the meantime.",
135+
"After the user confirms, run:",
136+
" univer-workspace-cli login --complete",
137+
].join("\n"),
138+
);
30139
});
31140

32141
const whoami = new Command("whoami")

0 commit comments

Comments
 (0)