Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import chalk from "chalk";
import ora from "ora";
import { isLoadError, loadConfig } from "../config/loader.js";
import { resolveApiClient } from "../internal/api-client.js";
import { addContext, getServerConfig } from "../internal/context.js";
import {
addContext,
getCurrentContextName,
getServerConfig,
setActiveOrg,
setCurrentContext,
} from "../internal/context.js";
import { type Credentials, saveCredentials } from "../internal/credentials.js";
import { parseEnvContent } from "../internal/index.js";
import { loadProjectLink } from "../internal/project-link.js";
Expand Down Expand Up @@ -412,6 +418,7 @@ async function announceLocalSignIn(
device_token?: string;
session_token?: string;
user?: { id?: string; email?: string; name?: string };
organization?: { id?: string; slug?: string; name?: string };
};
// CLI gets the worker-scoped PAT — works against /api/workers/* (used
// by lobu apply and everything else). The session_token is
Expand All @@ -431,6 +438,32 @@ async function announceLocalSignIn(
...(body.user?.id ? { userId: body.user.id } : {}),
};
await saveCredentials(creds, contextName);
// Bind the bootstrap org slug returned by /api/local-init to the
// context. Without this, `lobu apply -c local` errors with
// "No organization selected" until the user manually runs
// `lobu org set <slug>`. The server is the source of truth — it
// auto-provisioned this org for the install operator.
const orgSlug = body.organization?.slug?.trim();
if (orgSlug) {
await setActiveOrg(orgSlug, contextName).catch(() => undefined);
}
// Auto-switch the active context so plain `lobu apply` / `lobu chat`
// from any shell hit this loopback server instead of whatever cloud
// context was active. Announce on stderr when we actually flip so the
// user isn't surprised — `lobu run` on a fresh box silently lands on
// `local`; `lobu run` from a shell previously on `lobu` cloud prints
// the switch.
try {
const current = await getCurrentContextName();
if (current !== contextName) {
await setCurrentContext(contextName);
process.stderr.write(
`Switched active context to "${contextName}" (lobu run)\n`
);
}
} catch {
// Best-effort — failing to switch shouldn't kill the run banner.
}

const url = new URL(gatewayUrl);
url.searchParams.set("lobu_token", body.session_token ?? cliToken);
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/internal/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
DEFAULT_CONTEXT_NAME,
getCurrentContextName,
LOBU_CONFIG_DIR,
resolveContext,
setActiveOrg,
setCurrentContext,
} from "./context.js";
import { refreshTokens } from "./oauth.js";

Expand Down Expand Up @@ -171,6 +174,7 @@ async function tryLocalInit(contextName?: string): Promise<Credentials | null> {
device_token?: string;
session_token?: string;
user?: { id?: string; email?: string; name?: string };
organization?: { id?: string; slug?: string; name?: string };
};
// Prefer device_token (PAT scoped with device_worker:run + mcp:*) so
// `lobu chat` / `lobu apply` / worker poll all pass the scope gate on
Expand All @@ -185,6 +189,32 @@ async function tryLocalInit(contextName?: string): Promise<Credentials | null> {
...(body.user?.id ? { userId: body.user.id } : {}),
};
await saveCredentials(creds, target.name);
// Bind the local-init org slug to the context so `lobu apply` /
// `lobu chat` / `lobu org current` find it without a manual
// `lobu org set <slug>`. The server's bootstrap auto-provisions the
// single user's personal org and returns it in the response — that
// slug is the source of truth for this loopback install.
const orgSlug = body.organization?.slug?.trim();
if (orgSlug) {
await setActiveOrg(orgSlug, target.name).catch(() => undefined);
}
// Auto-switch the active context so subsequent `lobu apply` / `lobu chat`
// invocations (without `-c <name>`) hit the same loopback server. Without
// this, a user previously on the `lobu` cloud context who runs `lobu run`
// locally still sees cloud for every other command — and the fact that a
// local context exists is invisible. Announce on stderr so the change is
// visible but doesn't pollute stdout pipelines.
try {
const current = await getCurrentContextName();
if (current !== target.name) {
await setCurrentContext(target.name);
process.stderr.write(
`Switched active context to "${target.name}" (lobu run)\n`
);
}
} catch {
// Best-effort — a write failure here shouldn't break the auth flow.
}
return creds;
} catch {
return null;
Expand Down
16 changes: 15 additions & 1 deletion packages/server/src/auth/oauth/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type { DbClient } from '../../db/client';
import { PersonalAccessTokenService } from '../tokens';
import { OAuthClientsStore } from './clients';
import { AVAILABLE_SCOPES } from './scopes';
import type {
Expand Down Expand Up @@ -352,9 +353,22 @@ export class OAuthProvider {
// ============================================

/**
* Verify an access token and return auth info
* Verify an access token and return auth info.
*
* Accepts both OAuth 2.1 access tokens (`oauth_tokens` rows) and Personal
* Access Tokens (`personal_access_tokens` rows, prefix `owl_pat_`). The
* `/oauth/userinfo` route delegates here, so making PATs work for OAuth
* introspection lets a single bearer token authenticate against
* `/oauth/userinfo`, `/api/<orgSlug>/*`, the gateway's `/lobu/api/v1/*`
* via `createApiAuthMiddleware`, and the CLI's `lobu apply`
* org-resolution call. Without this, `lobu chat -c local` against the
* embedded server fails with a 404/401 even after a successful
* `/api/local-init` because the gateway can't introspect the PAT.
*/
async verifyAccessToken(token: string): Promise<AuthInfo | null> {
if (token.startsWith('owl_pat_')) {
return new PersonalAccessTokenService(this.sql).verify(token);
}
const tokenHash = hashToken(token);

const result = await this.sql`
Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/auth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,12 @@ credentialRoutes.post('/local-init', async (c) => {
'local-init',
{
description: 'Auto-minted by POST /api/local-init for local-runner clients.',
scope: 'device_worker:run mcp:read mcp:write mcp:admin',
// `profile:read` lets the same PAT hit `/oauth/userinfo`, which the
// gateway's `createApiAuthMiddleware` (used by `/lobu/api/v1/agents/*`)
// and the CLI's `lobu apply` org-resolution path both call. Without it
// the PAT works for `/api/<orgSlug>/*` and worker poll but is rejected
// by the agent-session and userinfo endpoints with a confusing 403/404.
scope: 'device_worker:run mcp:read mcp:write mcp:admin profile:read',
}
);

Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/gateway/routes/public/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,7 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono {
: undefined,
nixConfig,
agentId,
...(tokenOrganizationId ? { organizationId: tokenOrganizationId } : {}),
dryRun: effectiveDryRun,
intent: watcherIntent ?? undefined,
isEphemeral,
Expand Down Expand Up @@ -1210,6 +1211,9 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono {
channelId,
teamId: "api",
agentId: realAgentId,
...(session.organizationId
? { organizationId: session.organizationId }
: {}),
botId: "lobu-api",
platform: "api",
messageText: messageContent,
Expand Down
9 changes: 9 additions & 0 deletions packages/server/src/gateway/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export interface ThreadSession {
nixConfig?: NixConfig;
/** Original agent ID (before composite session key generation) */
agentId?: string;
/**
* Owning organization of the agent. Cached at session-create time so the
* message-send path can stamp it on the queue payload without re-reading
* `agent_metadata` for every message. EmbeddedDeploymentManager refuses
* `grantStore.grant()` calls without an org id, so without this the very
* first message after `lobu chat` would fail at worker spawn even though
* the session itself was created cleanly.
*/
organizationId?: string;
/** Process without persisting history */
dryRun?: boolean;
/** Internal automation intent for one-shot system sessions. */
Expand Down
Loading