From aa311b0589b008d184e670ae9d81a97b46953533 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 1 Jul 2026 22:48:25 -0300 Subject: [PATCH] Unify git credentials under `drive credential` + remote management (1.51.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the node-host-local `omnyshell node git-credential` with a single `omnyshell drive credential {add,list,remove}` group, mode chosen explicitly: - `--local` — manage credentials on this node host (~/.omnyshell/git-credentials.json), with --global (default) or --for-principal

; supports --ssh-key. - `--node ` — manage YOUR OWN credentials on a remote node over the hub (HTTPS only). Scoped to the calling principal. Transport is a hub-brokered control-RPC (DriveCredentialRequest → hub → NodeDriveCredentialRequest → response), mirroring the sessions screen/kill RPCs. The hub stamps the authenticated principal, so a client can only ever read or modify its own credentials on the node — never the global scope or another principal's. The hub authorizes it exactly like opening a drive session (canOpenSession, mode: drive); the node additionally requires driveEnabled and only ever mutates scopeFor(principal: ). Concurrent RPCs are serialized on the node to avoid load/save races. NodeConfig gains an optional gitCredentialsHome (used for both the drive-open resolver load and the RPC handler) so a node's store is relocatable — enabling per-node test isolation. Tests: codec round-trip for the new message quartet; end-to-end TestCluster integration proving per-principal scoping (add/list/remove; one principal never sees another's; stored under the caller, never global). Bumps to 1.51.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 + README.md | 49 ++- bin/omnyshell.dart | 341 +++++++++++++----- .../application/client/client_runtime.dart | 84 +++++ lib/src/application/hub/hub_broker.dart | 62 ++++ lib/src/application/node/node_runtime.dart | 89 ++++- lib/src/protocol/control_message.dart | 240 ++++++++++++ lib/src/protocol/frame_codec.dart | 4 + lib/src/version.dart | 2 +- pubspec.yaml | 2 +- .../drive_credential_remote_test.dart | 117 ++++++ test/support/harness.dart | 2 + .../drive_credential_messages_test.dart | 85 +++++ 13 files changed, 982 insertions(+), 114 deletions(-) create mode 100644 test/integration/drive_credential_remote_test.dart create mode 100644 test/unit/protocol/drive_credential_messages_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2614b36..9ef4b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## 1.51.0 + +### Changed + +- **Git credentials moved to `omnyshell drive credential`** and gained **remote + management**. The v1.50.0 `omnyshell node git-credential` command is **removed**; + all git-credential management is now under `drive credential`, with the mode chosen + explicitly: + - **`--local`** — manage credentials on **this node host** + (`~/.omnyshell/git-credentials.json`, mode 600), with `--global` (default) or + `--for-principal

`; supports `--ssh-key`/`--passphrase`. + - **`--node `** — manage **your own** credentials on a **remote** node over the + hub (HTTPS only: `--pat`, `--username/--password`). Scoped to the calling principal: + the hub stamps the authenticated principal, so a client can only ever read/modify its + own credentials on that node — never the global scope or another principal's. Gated by + the same authorization as opening a drive on the node. + - Subcommands: `add `, `list`, `remove `; remote mode uses the shared + connection flags (`--hub`, `-u/--principal`, `-t/--token`, `--key`, `--ca`, …). + ## 1.50.0 ### Added diff --git a/README.md b/README.md index 045c89e..185cd80 100644 --- a/README.md +++ b/README.md @@ -531,39 +531,48 @@ omnyshell drive mount --git https://github.com/acme/app.git \ ``` **Private remotes.** Because the *node* runs the clone/fetch/push, git -authentication is configured **on the node host**. Register a per-host credential -there with `omnyshell node git-credential`; it is picked up automatically for any -git mount whose URL matches that host — no per-mount flags: +credentials live **on the node host**. Manage them with `omnyshell drive +credential`; a credential is picked up automatically for any git mount whose URL +matches that host — no per-mount flags. Choose the target explicitly with +`--local` (this node host) or `--node ` (a remote node, over the hub). + +**Remote — manage _your own_ credentials on a node (HTTPS only):** ```sh -# On the node host: -omnyshell node git-credential add github.com --pat # HTTPS PAT -omnyshell node git-credential add gitlab.com --username me --password -omnyshell node git-credential add github.com --ssh-key ~/.ssh/id_ed25519 -omnyshell node git-credential list # secrets masked -omnyshell node git-credential remove github.com +omnyshell drive credential add github.com --pat --node web-01 +omnyshell drive credential add gitlab.com --username me --password --node web-01 +omnyshell drive credential list --node web-01 # your entries, masked +omnyshell drive credential remove github.com --node web-01 ``` -A credential is either **global** (node-wide) or scoped to a connecting -**principal** with `--principal

`: +Remote management is **scoped to the calling principal**: the hub stamps your +authenticated identity, so you can only ever read or modify your own credentials on +that node — never the global scope or another principal's. It is authorized exactly +like opening a git drive on that node. + +**Local — node-host admin (run on the node), full scope control:** ```sh -omnyshell node git-credential add github.com --pat # global -omnyshell node git-credential add github.com --pat --principal alice -omnyshell node git-credential list --principal alice -omnyshell node git-credential remove github.com --principal alice +# On the node host: +omnyshell drive credential add github.com --pat --local # global (default) +omnyshell drive credential add github.com --pat --local --for-principal alice +omnyshell drive credential add github.com --ssh-key ~/.ssh/id_ed25519 --local +omnyshell drive credential list --local # secrets masked +omnyshell drive credential remove github.com --local ``` -When a client opens a git mount, the node resolves the remote's credential -**principal-first, then global**: it uses the connecting (hub-authenticated) -principal's credential for that host, falling back to the node's global credential -when the principal has none. One principal never sees another's credential. +A credential is either **global** (node-wide) or scoped to a **principal**. When a +client opens a git mount, the node resolves the remote's credential +**principal-first, then global**, independently per host: it uses the connecting +(hub-authenticated) principal's credential for that host, falling back to the node's +global credential when the principal has none. One principal never sees another's. Credentials are stored in `~/.omnyshell/git-credentials.json` (mode `600`), separate from the client/hub auth store. They live **only** on the node: they are never sent to the Hub or peers and never serialized onto a mount (`mounts.json`) or drive. A missing or wrong credential fails fast rather than hanging on an -interactive prompt. (SSH keys with a passphrase require an ssh-agent.) +interactive prompt. (SSH keys with a passphrase require an ssh-agent, and can only +be set with `--local` since the key path is node-side.) Restrict which sub-paths a directory mount serves with repeatable `--include` (whitelist) / `--exclude` (wins over include) globs — the filter is baked into the diff --git a/bin/omnyshell.dart b/bin/omnyshell.dart index 795f483..26b5ccf 100644 --- a/bin/omnyshell.dart +++ b/bin/omnyshell.dart @@ -1660,7 +1660,6 @@ class NodeCommand extends Command { NodeCommand() { addSubcommand(NodeStartCommand()); addSubcommand(NodeProfileCommand()); - addSubcommand(NodeGitCredentialCommand()); } @override @@ -1670,51 +1669,130 @@ class NodeCommand extends Command { String get description => 'Run and manage a Node.'; } -/// `omnyshell node git-credential` — manage git credentials for private -/// remotes. These live only on **this node host** (git clone/push runs here), -/// in `~/.omnyshell/git-credentials.json` (mode 600); they are never sent to the -/// hub, peers, or serialized onto a drive. +/// `omnyshell drive credential` — manage git credentials used to clone/pull +/// private git drives. Two modes, chosen explicitly: /// -/// A credential is either **global** (node-wide) or scoped to a connecting -/// **principal** via `--principal

`. At mount time the node resolves a host's -/// credential principal-first, then falls back to the global one. -class NodeGitCredentialCommand extends Command { - NodeGitCredentialCommand() { - addSubcommand(NodeGitCredentialAddCommand()); - addSubcommand(NodeGitCredentialListCommand()); - addSubcommand(NodeGitCredentialRemoveCommand()); +/// * `--local` — manage credentials on **this node host** +/// (`~/.omnyshell/git-credentials.json`, mode 600), with operator scope +/// control (`--global`, the default, or `--for-principal

`); supports SSH +/// keys. +/// * `--node ` — manage **your own** credentials on a remote node, over +/// the hub (HTTPS only). Scoped to the calling principal: the node stores +/// them under that principal and never reveals the global scope or another +/// principal's credentials. +/// +/// Credentials always live only on the node — never sent to the hub, peers, or +/// serialized onto a drive. At mount time the node resolves a host's credential +/// principal-first, then falls back to the global one. +class DriveCredentialCommand extends Command { + DriveCredentialCommand() { + addSubcommand(DriveCredentialAddCommand()); + addSubcommand(DriveCredentialListCommand()); + addSubcommand(DriveCredentialRemoveCommand()); } @override - String get name => 'git-credential'; + String get name => 'credential'; @override String get description => - 'Manage git credentials this node uses to reach private remotes.'; + 'Manage git credentials for private git drives (local or remote).'; } -/// Adds the shared `--principal` option: absent means the global (node-wide) -/// scope, otherwise the credential is scoped to that connecting principal. -void _addPrincipalOption(ArgParser parser) => parser.addOption( - 'principal', - help: 'Scope to a connecting principal (default: global, node-wide).', -); +/// Adds the shared `--local`/`--node` mode selector plus the connection options +/// used in remote (`--node`) mode. +void _addCredentialTargetOptions(ArgParser p) { + p + ..addFlag( + 'local', + negatable: false, + help: 'Manage credentials on THIS node host.', + ) + ..addOption( + 'node', + help: 'Manage MY credentials on a remote node (over the hub).', + ); + _addConnectionOptions(p); +} + +/// Resolves the target mode, requiring exactly one of `--local` / `--node`. +({bool local, String? node}) _credentialTarget(ArgResults args) { + final local = args['local'] as bool; + final node = args['node'] as String?; + if (local && node != null) { + throw _CliError('Use either --local or --node , not both.'); + } + if (!local && node == null) { + throw _CliError( + 'Specify --local (this node host) or --node (remote over the hub).', + ); + } + return (local: local, node: node); +} + +/// Builds an HTTPS credential (PAT or username/password) from [args]. +GitCredential _httpsCredential(ArgResults args) { + final pat = args['pat'] as String?; + final username = args['username'] as String?; + final password = args['password'] as String?; + if (pat != null) { + return GitPat(token: pat, username: username ?? 'x-access-token'); + } + if (username != null && password != null) { + return GitUserPass(username: username, password: password); + } + throw _CliError('Provide --pat, or --username with --password.'); +} + +/// Builds a credential for local mode, additionally allowing an SSH key. +GitCredential _localCredential(ArgResults args) { + final sshKey = args['ssh-key'] as String?; + if (sshKey != null) { + return GitSshKey( + keyPath: sshKey, + passphrase: args['passphrase'] as String?, + ); + } + return _httpsCredential(args); +} + +void _printLocalStore(GitCredentialStore store) { + for (final host in store.hosts) { + stdout.writeln(' $host ${store.get(host)}'); + } +} + +/// A human-readable scope suffix for local CLI messages. +String _scopeSuffix(String? principal) => + principal == null ? ' (global)' : ' (principal $principal)'; -class NodeGitCredentialAddCommand extends Command { - NodeGitCredentialAddCommand() { +class DriveCredentialAddCommand extends Command { + DriveCredentialAddCommand() { argParser ..addOption('pat', help: 'HTTPS personal access token.') ..addOption( 'username', - help: 'Username (with --password, or to override the PAT username).', + help: 'HTTPS username (with --password, or to override the PAT user).', ) ..addOption('password', help: 'HTTPS password (with --username).') - ..addOption('ssh-key', help: 'Path to an SSH private key.') + ..addOption( + 'ssh-key', + help: '(--local only) path to an SSH private key on the node host.', + ) ..addOption( 'passphrase', - help: 'SSH key passphrase (requires an ssh-agent to take effect).', + help: '(--local only) SSH key passphrase (requires an ssh-agent).', + ) + ..addFlag( + 'global', + negatable: false, + help: '(--local) node-wide scope (the default).', + ) + ..addOption( + 'for-principal', + help: '(--local) scope to a specific principal.', ); - _addPrincipalOption(argParser); + _addCredentialTargetOptions(argParser); } @override @@ -1722,14 +1800,14 @@ class NodeGitCredentialAddCommand extends Command { @override String get description => - 'Store a credential for a git host (e.g. github.com) on this node.'; + 'Store a git credential for a host (e.g. github.com).'; @override String? get usageFooter => _usageExamples([ - 'omnyshell node git-credential add github.com --pat ', - 'omnyshell node git-credential add gitlab.com --username me --password ', - 'omnyshell node git-credential add github.com --ssh-key ~/.ssh/id_ed25519', - 'omnyshell node git-credential add github.com --pat --principal alice', + 'omnyshell drive credential add github.com --pat --node web-01', + 'omnyshell drive credential add github.com --username me --password --node web-01', + 'omnyshell drive credential add github.com --pat --local', + 'omnyshell drive credential add github.com --ssh-key ~/.ssh/id_ed25519 --local', ]); @override @@ -1738,68 +1816,120 @@ class NodeGitCredentialAddCommand extends Command { final rest = args.rest; if (rest.isEmpty) { throw _CliError( - 'Usage: omnyshell node git-credential add ' - '(--pat | --username --password

| --ssh-key ) ' - '[--principal

]', + 'Usage: omnyshell drive credential add ' + '(--pat | --username --password

[| --ssh-key --local]) ' + '(--local | --node )', ); } final host = rest.first; - final principal = args['principal'] as String?; - final pat = args['pat'] as String?; - final username = args['username'] as String?; - final password = args['password'] as String?; - final sshKey = args['ssh-key'] as String?; - final passphrase = args['passphrase'] as String?; - - final GitCredential credential; - if (pat != null) { - credential = GitPat(token: pat, username: username ?? 'x-access-token'); - } else if (sshKey != null) { - credential = GitSshKey(keyPath: sshKey, passphrase: passphrase); - } else if (username != null && password != null) { - credential = GitUserPass(username: username, password: password); - } else { - throw _CliError( - 'Provide one of: --pat, --ssh-key, or --username with --password.', - ); + final target = _credentialTarget(args); + + // Remote: caller-scoped, HTTPS only. + if (target.node != null) { + if ((args['ssh-key'] as String?) != null) { + throw _CliError( + '--ssh-key is not supported remotely (its path is node-side); ' + 'use --local on the node host.', + ); + } + if ((args['for-principal'] as String?) != null || + args['global'] as bool) { + throw _CliError( + '--global/--for-principal apply only to --local; a remote credential ' + 'is always scoped to your own principal.', + ); + } + final credential = _httpsCredential(args); + final client = await _connectClient(args); + try { + final res = await client.driveCredentialAdd( + nodeId: target.node!, + host: host, + credential: credential.toJson(), + ); + if (!res.ok) { + throw _CliError( + res.message.isEmpty ? 'failed to store credential' : res.message, + ); + } + stdout.writeln( + res.message.isEmpty + ? 'Stored credential for $host on ${target.node}.' + : res.message, + ); + } finally { + await client.close(); + } + return; } + // Local (node host). + final forPrincipal = args['for-principal'] as String?; + final credential = _localCredential(args); final creds = await NodeGitCredentials.load(); - creds.scopeFor(principal: principal).put(host, credential); + creds.scopeFor(principal: forPrincipal).put(host, credential); await creds.save(); - stdout.writeln('Stored $credential for $host${_scopeSuffix(principal)}.'); + stdout.writeln( + 'Stored $credential for $host${_scopeSuffix(forPrincipal)}.', + ); } } -class NodeGitCredentialListCommand extends Command { - NodeGitCredentialListCommand() { - _addPrincipalOption(argParser); +class DriveCredentialListCommand extends Command { + DriveCredentialListCommand() { + argParser.addOption( + 'for-principal', + help: '(--local) show only this principal.', + ); + _addCredentialTargetOptions(argParser); } @override String get name => 'list'; @override - String get description => - 'List git credentials stored on this node (secrets masked).'; + String get description => 'List git credentials (secrets masked).'; @override Future run() async { - final principal = argResults!['principal'] as String?; - final creds = await NodeGitCredentials.load(); + final args = argResults!; + final target = _credentialTarget(args); + + // Remote: only the caller's own credentials. + if (target.node != null) { + final client = await _connectClient(args); + try { + final res = await client.driveCredentialList(nodeId: target.node!); + if (!res.ok) { + throw _CliError( + res.message.isEmpty ? 'failed to list credentials' : res.message, + ); + } + if (res.entries.isEmpty) { + stdout.writeln('No git credentials.'); + return; + } + for (final e in res.entries) { + stdout.writeln(' ${e.host} ${e.description}'); + } + } finally { + await client.close(); + } + return; + } - // Scoped view: only the requested principal. - if (principal != null) { - final store = creds.storeFor(principal); + // Local (node host). + final forPrincipal = args['for-principal'] as String?; + final creds = await NodeGitCredentials.load(); + if (forPrincipal != null) { + final store = creds.storeFor(forPrincipal); if (store == null || store.hosts.isEmpty) { - stdout.writeln('No git credentials for principal $principal.'); + stdout.writeln('No git credentials for principal $forPrincipal.'); return; } - _printStore(store); + _printLocalStore(store); return; } - - // Full view: the global section, then each principal section. final hasGlobal = creds.global.hosts.isNotEmpty; final principals = creds.principals; if (!hasGlobal && principals.isEmpty) { @@ -1808,31 +1938,29 @@ class NodeGitCredentialListCommand extends Command { } if (hasGlobal) { stdout.writeln('[global]'); - _printStore(creds.global); + _printLocalStore(creds.global); } for (final p in principals) { stdout.writeln('[principal: $p]'); - _printStore(creds.storeFor(p)!); - } - } - - void _printStore(GitCredentialStore store) { - for (final host in store.hosts) { - stdout.writeln(' $host ${store.get(host)}'); + _printLocalStore(creds.storeFor(p)!); } } } -class NodeGitCredentialRemoveCommand extends Command { - NodeGitCredentialRemoveCommand() { - _addPrincipalOption(argParser); +class DriveCredentialRemoveCommand extends Command { + DriveCredentialRemoveCommand() { + argParser.addOption( + 'for-principal', + help: '(--local) scope to a specific principal.', + ); + _addCredentialTargetOptions(argParser); } @override String get name => 'remove'; @override - String get description => 'Remove the stored credential for a git host.'; + String get description => 'Remove a stored git credential for a host.'; @override Future run() async { @@ -1840,27 +1968,59 @@ class NodeGitCredentialRemoveCommand extends Command { final rest = args.rest; if (rest.isEmpty) { throw _CliError( - 'Usage: omnyshell node git-credential remove [--principal

]', + 'Usage: omnyshell drive credential remove (--local | --node )', ); } final host = rest.first; - final principal = args['principal'] as String?; + final target = _credentialTarget(args); + + // Remote: caller-scoped. + if (target.node != null) { + if ((args['for-principal'] as String?) != null) { + throw _CliError('--for-principal applies only to --local.'); + } + final client = await _connectClient(args); + try { + final res = await client.driveCredentialRemove( + nodeId: target.node!, + host: host, + ); + if (!res.ok) { + throw _CliError( + res.message.isEmpty + ? 'no credential stored for $host' + : res.message, + ); + } + stdout.writeln( + res.message.isEmpty + ? 'Removed credential for $host on ${target.node}.' + : res.message, + ); + } finally { + await client.close(); + } + return; + } + + // Local (node host). + final forPrincipal = args['for-principal'] as String?; final creds = await NodeGitCredentials.load(); - final store = principal == null ? creds.global : creds.storeFor(principal); + final store = forPrincipal == null + ? creds.global + : creds.storeFor(forPrincipal); if (store == null || !store.remove(host)) { throw _CliError( - 'No credential stored for $host${_scopeSuffix(principal)}.', + 'No credential stored for $host${_scopeSuffix(forPrincipal)}.', ); } await creds.save(); - stdout.writeln('Removed credential for $host${_scopeSuffix(principal)}.'); + stdout.writeln( + 'Removed credential for $host${_scopeSuffix(forPrincipal)}.', + ); } } -/// A human-readable scope suffix for CLI messages. -String _scopeSuffix(String? principal) => - principal == null ? ' (global)' : ' (principal $principal)'; - /// Resolves the shell whose rc the profile sync reads (and sessions run): /// `--shell` override, else `$SHELL` (`%COMSPEC%` on Windows). String _resolveNodeShell(String? override) { @@ -4985,6 +5145,7 @@ class DriveCommand extends Command { addSubcommand(DriveResolveCommand()); addSubcommand(DriveUnmountCommand()); addSubcommand(DriveRemountCommand()); + addSubcommand(DriveCredentialCommand()); } @override diff --git a/lib/src/application/client/client_runtime.dart b/lib/src/application/client/client_runtime.dart index ec740ce..f16145a 100644 --- a/lib/src/application/client/client_runtime.dart +++ b/lib/src/application/client/client_runtime.dart @@ -157,6 +157,25 @@ class HubAiConfig { /// The result of [ClientRuntime.peekSession]: the current screen snapshot of a /// session, captured without attaching to it. +/// The result of a `drive credential` RPC against a node. +class DriveCredentialResult { + /// Whether the operation succeeded. + final bool ok; + + /// A human-readable result/error message. + final String message; + + /// The caller's masked credentials (for `list`; empty otherwise). + final List entries; + + /// Creates a drive-credential result. + const DriveCredentialResult({ + required this.ok, + required this.message, + this.entries = const [], + }); +} + class SessionScreenResult { /// Whether a session was found, owned by the caller, and captured. final bool ok; @@ -264,6 +283,8 @@ class ClientRuntime { final Map> _pendingActiveDetach = {}; final Map> _pendingSessionScreens = {}; + final Map> _pendingDriveCredentials = + {}; final Map< String, ({Completer completer, String nodeId, int targetPort}) @@ -348,6 +369,16 @@ class ClientRuntime { altScreen: resp.altScreen, ), ); + case final DriveCredentialResponse resp: + _pendingDriveCredentials + .remove(resp.requestId) + ?.complete( + DriveCredentialResult( + ok: resp.ok, + message: resp.message, + entries: resp.entries, + ), + ); case final DetachedSessionKillResponse resp: _pendingSessionKills .remove(resp.requestId) @@ -602,6 +633,53 @@ class ClientRuntime { return completer.future; } + /// Adds/overwrites the caller's own git credential for [host] on [nodeId]. + /// [credential] is an omnydrive `GitCredential` JSON map. + Future driveCredentialAdd({ + required String nodeId, + required String host, + required Map credential, + }) => _driveCredential( + nodeId: nodeId, + op: 'add', + host: host, + credential: credential, + ); + + /// Lists the caller's own git credentials on [nodeId] (secrets masked). + Future driveCredentialList({required String nodeId}) => + _driveCredential(nodeId: nodeId, op: 'list'); + + /// Removes the caller's own git credential for [host] on [nodeId]. + Future driveCredentialRemove({ + required String nodeId, + required String host, + }) => _driveCredential(nodeId: nodeId, op: 'remove', host: host); + + Future _driveCredential({ + required String nodeId, + required String op, + String? host, + Map? credential, + }) { + _ensureConnected(); + final id = newId(); + final completer = Completer(); + _pendingDriveCredentials[id] = completer; + _connection!.send( + ControlFrame( + DriveCredentialRequest( + requestId: id, + nodeId: nodeId, + op: op, + host: host, + credential: credential, + ), + ), + ); + return completer.future; + } + /// Detaches one of the caller's *active* sessions on [nodeId] from this /// connection — used to leave a session whose terminal is busy with a /// full-screen program. [sessionRef] is a full id, short handle or prefix; @@ -772,6 +850,12 @@ class ClientRuntime { } } _pendingSessionScreens.clear(); + for (final completer in _pendingDriveCredentials.values) { + if (!completer.isCompleted) { + completer.completeError(const TransportException('Disconnected')); + } + } + _pendingDriveCredentials.clear(); for (final pending in _pendingTunnelOpens.values) { if (!pending.completer.isCompleted) { pending.completer.completeError( diff --git a/lib/src/application/hub/hub_broker.dart b/lib/src/application/hub/hub_broker.dart index 67d1496..be32cb2 100644 --- a/lib/src/application/hub/hub_broker.dart +++ b/lib/src/application/hub/hub_broker.dart @@ -406,6 +406,16 @@ class HubBroker { altScreen: resp.altScreen, ), ); + case final NodeDriveCredentialResponse resp: + _completeNodeRpc( + resp.requestId, + DriveCredentialResponse( + requestId: resp.requestId, + ok: resp.ok, + message: resp.message, + entries: resp.entries, + ), + ); case final ChannelExit exit: _relayNodeToClient(peer, exit.channel, exit); case final ChannelWindow window: @@ -472,6 +482,8 @@ class HubBroker { _handleDetachedSessionKill(peer, req); case final SessionScreenRequest req: _handleSessionScreenRequest(peer, req); + case final DriveCredentialRequest req: + await _handleDriveCredentialRequest(peer, req); case final ActiveSessionDetachRequest req: _handleActiveSessionDetach(peer, req); case final TunnelOpenRequest req: @@ -711,6 +723,56 @@ class HubBroker { ); } + /// Client → Node: manage the caller's own git credentials on a node. Gated by + /// the same authorization as opening a drive session; the principal is stamped + /// from the authenticated peer so a client can only ever touch its own scope. + Future _handleDriveCredentialRequest( + HubPeer peer, + DriveCredentialRequest req, + ) async { + void reply(bool ok, String message) => peer.connection.send( + ControlFrame( + DriveCredentialResponse( + requestId: req.requestId, + ok: ok, + message: message, + ), + ), + ); + + final node = _onlineNode(req.nodeId); + if (node == null) { + reply(false, 'Node offline'); + return; + } + final principal = peer.principal; + if (principal == null) { + reply(false, 'Not authenticated'); + return; + } + final allowed = await authorizer.canOpenSession( + principal: principal, + node: node.descriptor, + mode: SessionMode.drive, + ); + if (!allowed) { + reply(false, 'Not authorized'); + return; + } + _pendingNodeRpc[req.requestId] = peer; + node.peer.connection.send( + ControlFrame( + NodeDriveCredentialRequest( + requestId: req.requestId, + principal: principal.id.value, + op: req.op, + host: req.host, + credential: req.credential, + ), + ), + ); + } + /// Client → Node: kill one of the caller's detached sessions on a node. void _handleDetachedSessionKill( HubPeer peer, diff --git a/lib/src/application/node/node_runtime.dart b/lib/src/application/node/node_runtime.dart index 894a3e7..096f3d1 100644 --- a/lib/src/application/node/node_runtime.dart +++ b/lib/src/application/node/node_runtime.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:omnydrive/omnydrive.dart' show GitCredentialResolver; +import 'package:omnydrive/omnydrive.dart' + show GitCredential, GitCredentialResolver; import '../../domain/backend/shell_backend.dart'; import '../../domain/backend/shell_request.dart'; @@ -121,6 +122,11 @@ class NodeConfig { /// resolved path is not under one of these roots. final List driveRoots; + /// Overrides the home directory used to load/save this node's git credentials + /// (`/.omnyshell/git-credentials.json`). `null` uses the process default + /// ([omnyshellHome]). Mainly for tests and multi-node isolation. + final String? gitCredentialsHome; + /// Whether a client connection that drops (network loss, crash, terminal /// closed) automatically detaches its session — keeping the PTY, shell and /// child processes alive for a later resume — instead of terminating it. @@ -158,6 +164,7 @@ class NodeConfig { this.driveEnabled = true, this.tunnelEnabled = true, this.driveRoots = const [], + this.gitCredentialsHome, this.autoDetachOnDisconnect = true, this.autoDetachTimeout, this.cleanupInterval = const Duration(minutes: 1), @@ -302,6 +309,8 @@ class NodeRuntime { await _onKillSession(req); case final NodeSessionScreenRequest req: _onScreenRequest(req); + case final NodeDriveCredentialRequest req: + await _onDriveCredentialRequest(req); case final NodeActiveSessionDetach req: await _onDetachActive(req); case final Ping ping: @@ -431,7 +440,9 @@ class NodeRuntime { // must never block a session, so failures degrade to no credentials. GitCredentialResolver? gitCreds; try { - final creds = await NodeGitCredentials.load(); + final creds = await NodeGitCredentials.load( + home: config.gitCredentialsHome, + ); gitCreds = creds.resolverFor(open.principal); } on Object catch (e) { config.logger?.call('[drive] ignoring git credentials: $e'); @@ -931,6 +942,80 @@ class NodeRuntime { /// Returns the current screen snapshot of one of the caller's sessions — /// *running* (attached) or detached — named by [req.sessionRef], without /// disturbing it. The replayed bytes are exactly what a resume would paint. + // Serializes drive-credential RPCs so concurrent load/mutate/save never race. + Future _credChain = Future.value(); + + /// Hub → Node: manage the caller's own git credentials. [req.principal] is + /// hub-stamped, so this only ever touches that principal's scope — never the + /// global scope and never a client-supplied principal. + Future _onDriveCredentialRequest(NodeDriveCredentialRequest req) { + return _credChain = _credChain.then((_) => _applyDriveCredential(req)); + } + + Future _applyDriveCredential(NodeDriveCredentialRequest req) async { + final home = config.gitCredentialsHome; + var ok = false; + var message = ''; + var entries = const []; + try { + if (!config.driveEnabled) { + message = 'git credential management is disabled on this node'; + } else { + final creds = await NodeGitCredentials.load(home: home); + switch (req.op) { + case 'add': + final host = req.host; + final credJson = req.credential; + if (host == null || credJson == null) { + message = 'host and credential are required'; + break; + } + creds + .scopeFor(principal: req.principal) + .put(host, GitCredential.fromJson(credJson)); + await creds.save(home: home); + ok = true; + message = 'stored credential for $host'; + case 'remove': + final host = req.host; + final store = creds.storeFor(req.principal); + if (host == null) { + message = 'host is required'; + } else if (store == null || !store.remove(host)) { + message = 'no credential stored for $host'; + } else { + await creds.save(home: home); + ok = true; + message = 'removed credential for $host'; + } + case 'list': + final store = creds.storeFor(req.principal); + entries = [ + if (store != null) + for (final h in store.hosts) + DriveCredentialEntry(host: h, description: '${store.get(h)}'), + ]; + ok = true; + default: + message = 'unknown op: ${req.op}'; + } + } + } on Object catch (e) { + ok = false; + message = '$e'; + } + _connection?.send( + ControlFrame( + NodeDriveCredentialResponse( + requestId: req.requestId, + ok: ok, + message: message, + entries: entries, + ), + ), + ); + } + void _onScreenRequest(NodeSessionScreenRequest req) { // Active (attached) shell sessions owned by the caller that match the ref. final activeMatches = _sessions.values diff --git a/lib/src/protocol/control_message.dart b/lib/src/protocol/control_message.dart index c8bdb7e..6609238 100644 --- a/lib/src/protocol/control_message.dart +++ b/lib/src/protocol/control_message.dart @@ -2104,6 +2104,246 @@ final class SessionScreenResponse extends ControlMessage { ); } +/// One masked git-credential entry returned by a `list` (secrets never leave the +/// node — [description] is the credential's masked `toString`). +class DriveCredentialEntry { + /// The git host (e.g. `github.com`). + final String host; + + /// The masked, human-readable credential description. + final String description; + + const DriveCredentialEntry({required this.host, required this.description}); + + Map toJson() => {'host': host, 'description': description}; + + static DriveCredentialEntry fromJson(Map d) => + DriveCredentialEntry( + host: Json.requireString(d, 'host'), + description: Json.requireString(d, 'description'), + ); +} + +List _decodeCredEntries(Object? raw) { + if (raw is! List) return const []; + return raw + .whereType() + .map((e) => DriveCredentialEntry.fromJson(e.cast())) + .toList(growable: false); +} + +/// Client → Hub: manage the caller's **own** git credentials on [nodeId]. +/// +/// [op] is `add` | `list` | `remove`. [host] is set for add/remove; [credential] +/// carries the omnydrive `GitCredential` JSON for add. The caller's principal is +/// NOT sent — the hub stamps it onto [NodeDriveCredentialRequest]. +final class DriveCredentialRequest extends ControlMessage { + /// The type discriminator. + static const String typeName = 'drive.credential.request'; + + /// The correlation id echoed in the response. + final String requestId; + + /// The target node. + final String nodeId; + + /// The operation: `add`, `list`, or `remove`. + final String op; + + /// The git host for add/remove (null for list). + final String? host; + + /// The omnydrive `GitCredential` JSON for add (null otherwise). + final Map? credential; + + /// Creates a drive-credential request. + const DriveCredentialRequest({ + required this.requestId, + required this.nodeId, + required this.op, + this.host, + this.credential, + }); + + @override + String get type => typeName; + + @override + Map toJson() => { + 'requestId': requestId, + 'nodeId': nodeId, + 'op': op, + if (host != null) 'host': host, + if (credential != null) 'credential': credential, + }; + + /// Decodes a [DriveCredentialRequest]. + static DriveCredentialRequest fromJson( + int? channel, + Map d, + ) => DriveCredentialRequest( + requestId: Json.requireString(d, 'requestId'), + nodeId: Json.requireString(d, 'nodeId'), + op: Json.requireString(d, 'op'), + host: Json.optString(d, 'host'), + credential: (d['credential'] as Map?)?.cast(), + ); +} + +/// Hub → Node: manage [principal]'s git credentials. The [principal] is stamped +/// by the hub from the authenticated peer; the node only ever mutates that +/// principal's scope. Correlated by [requestId]. +final class NodeDriveCredentialRequest extends ControlMessage { + /// The type discriminator. + static const String typeName = 'node.drive.credential.request'; + + /// The correlation id echoed in the response. + final String requestId; + + /// The hub-stamped owner whose credentials are managed. + final String principal; + + /// The operation: `add`, `list`, or `remove`. + final String op; + + /// The git host for add/remove (null for list). + final String? host; + + /// The omnydrive `GitCredential` JSON for add (null otherwise). + final Map? credential; + + /// Creates a node drive-credential request. + const NodeDriveCredentialRequest({ + required this.requestId, + required this.principal, + required this.op, + this.host, + this.credential, + }); + + @override + String get type => typeName; + + @override + Map toJson() => { + 'requestId': requestId, + 'principal': principal, + 'op': op, + if (host != null) 'host': host, + if (credential != null) 'credential': credential, + }; + + /// Decodes a [NodeDriveCredentialRequest]. + static NodeDriveCredentialRequest fromJson( + int? channel, + Map d, + ) => NodeDriveCredentialRequest( + requestId: Json.requireString(d, 'requestId'), + principal: Json.requireString(d, 'principal'), + op: Json.requireString(d, 'op'), + host: Json.optString(d, 'host'), + credential: (d['credential'] as Map?)?.cast(), + ); +} + +/// Node → Hub: the result of a drive-credential operation. [entries] carries the +/// masked credential list for `list`. Correlated by [requestId]. +final class NodeDriveCredentialResponse extends ControlMessage { + /// The type discriminator. + static const String typeName = 'node.drive.credential.response'; + + /// The correlation id from the request. + final String requestId; + + /// Whether the operation succeeded. + final bool ok; + + /// A human-readable result/error message. + final String message; + + /// The caller's masked credentials (for `list`; empty otherwise). + final List entries; + + /// Creates a node drive-credential response. + const NodeDriveCredentialResponse({ + required this.requestId, + required this.ok, + this.message = '', + this.entries = const [], + }); + + @override + String get type => typeName; + + @override + Map toJson() => { + 'requestId': requestId, + 'ok': ok, + if (message.isNotEmpty) 'message': message, + if (entries.isNotEmpty) 'entries': entries.map((e) => e.toJson()).toList(), + }; + + /// Decodes a [NodeDriveCredentialResponse]. + static NodeDriveCredentialResponse fromJson( + int? channel, + Map d, + ) => NodeDriveCredentialResponse( + requestId: Json.requireString(d, 'requestId'), + ok: Json.optBool(d, 'ok'), + message: Json.optString(d, 'message') ?? '', + entries: _decodeCredEntries(d['entries']), + ); +} + +/// Hub → Client: the result of a drive-credential operation. Correlated by +/// [requestId]. +final class DriveCredentialResponse extends ControlMessage { + /// The type discriminator. + static const String typeName = 'drive.credential.response'; + + /// The correlation id from the request. + final String requestId; + + /// Whether the operation succeeded. + final bool ok; + + /// A human-readable result/error message. + final String message; + + /// The caller's masked credentials (for `list`; empty otherwise). + final List entries; + + /// Creates a drive-credential response. + const DriveCredentialResponse({ + required this.requestId, + required this.ok, + this.message = '', + this.entries = const [], + }); + + @override + String get type => typeName; + + @override + Map toJson() => { + 'requestId': requestId, + 'ok': ok, + if (message.isNotEmpty) 'message': message, + if (entries.isNotEmpty) 'entries': entries.map((e) => e.toJson()).toList(), + }; + + /// Decodes a [DriveCredentialResponse]. + static DriveCredentialResponse fromJson( + int? channel, + Map d, + ) => DriveCredentialResponse( + requestId: Json.requireString(d, 'requestId'), + ok: Json.optBool(d, 'ok'), + message: Json.optString(d, 'message') ?? '', + entries: _decodeCredEntries(d['entries']), + ); +} + List _decodeSessions(Object? raw) { if (raw is! List) return const []; return raw diff --git a/lib/src/protocol/frame_codec.dart b/lib/src/protocol/frame_codec.dart index 36521b7..d847875 100644 --- a/lib/src/protocol/frame_codec.dart +++ b/lib/src/protocol/frame_codec.dart @@ -219,6 +219,10 @@ class FrameCodec { NodeSessionScreenRequest.typeName: NodeSessionScreenRequest.fromJson, NodeSessionScreenResponse.typeName: NodeSessionScreenResponse.fromJson, SessionScreenResponse.typeName: SessionScreenResponse.fromJson, + DriveCredentialRequest.typeName: DriveCredentialRequest.fromJson, + NodeDriveCredentialRequest.typeName: NodeDriveCredentialRequest.fromJson, + NodeDriveCredentialResponse.typeName: NodeDriveCredentialResponse.fromJson, + DriveCredentialResponse.typeName: DriveCredentialResponse.fromJson, TunnelOpenRequest.typeName: TunnelOpenRequest.fromJson, TunnelOpened.typeName: TunnelOpened.fromJson, TunnelRejected.typeName: TunnelRejected.fromJson, diff --git a/lib/src/version.dart b/lib/src/version.dart index cdb7002..d502126 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -3,4 +3,4 @@ /// This is the single source of truth for "what build is this": it is rendered /// in the CLI banner and is the default a node reports as its /// [NodeConfig.agentVersion] / [PlatformInfo.agentVersion]. -const String omnyShellVersion = '1.50.0'; +const String omnyShellVersion = '1.51.0'; diff --git a/pubspec.yaml b/pubspec.yaml index 6b2202b..1a4db74 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: >- connect to a Hub by node identity (not host:port); the Hub authenticates, authorizes and brokers encrypted sessions to Nodes over WebSocket-on-TLS. Ships Hub, Node, Client and CLI implementations behind first-class Dart APIs. -version: 1.50.0 +version: 1.51.0 repository: https://github.com/OmnyGrid/omnyshell environment: diff --git a/test/integration/drive_credential_remote_test.dart b/test/integration/drive_credential_remote_test.dart new file mode 100644 index 0000000..cd79061 --- /dev/null +++ b/test/integration/drive_credential_remote_test.dart @@ -0,0 +1,117 @@ +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:omnydrive/omnydrive.dart' show GitPat; +import 'package:omnyshell/omnyshell_hub.dart' show PrincipalId, TokenGrant; +import 'package:omnyshell/src/infrastructure/auth/node_git_credentials.dart'; +import 'package:test/test.dart'; + +import '../support/harness.dart'; + +/// End-to-end: a client manages its **own** git credentials on a remote node +/// over the hub (`ClientRuntime.driveCredential*` → hub relay → node handler), +/// proving the hub-stamped principal scopes every operation to the caller. +void main() { + late TestCluster cluster; + late Directory tmp; + late String nodeHome; + + setUp(() async { + cluster = await TestCluster.start( + tokens: { + 'node-token': TokenGrant( + principal: PrincipalId('node-account'), + roles: {'node'}, + ), + // Two admins so both are authorized to reach the node (drive access). + 'alice-token': TokenGrant( + principal: PrincipalId('alice'), + roles: {'admin'}, + ), + 'bob-token': TokenGrant( + principal: PrincipalId('bob'), + roles: {'admin'}, + ), + }, + ); + tmp = Directory.systemTemp.createTempSync('omnyshell-remote-cred'); + nodeHome = + tmp.path; // isolates the node's ~/.omnyshell/git-credentials.json + await cluster.startNode(id: 'web-01', gitCredentialsHome: nodeHome); + }); + + tearDown(() async { + await cluster.dispose(); + tmp.deleteSync(recursive: true); + }); + + test('remote add/list is scoped to the caller principal', () async { + final alice = await cluster.connectClient( + token: 'alice-token', + principal: 'alice', + ); + final bob = await cluster.connectClient( + token: 'bob-token', + principal: 'bob', + ); + + final add = await alice.driveCredentialAdd( + nodeId: 'web-01', + host: 'github.com', + credential: GitPat(token: 'ALICE-TOKEN').toJson(), + ); + expect(add.ok, isTrue, reason: add.message); + + final aliceList = await alice.driveCredentialList(nodeId: 'web-01'); + expect(aliceList.ok, isTrue); + expect(aliceList.entries.map((e) => e.host), contains('github.com')); + expect(aliceList.entries.single.description, contains('***')); // masked + + // bob sees none of alice's credentials. + final bobList = await bob.driveCredentialList(nodeId: 'web-01'); + expect(bobList.entries, isEmpty); + + // On disk: stored under alice only — never global, never bob. + final stored = await NodeGitCredentials.load(home: nodeHome); + expect(stored.storeFor('alice')!.get('github.com'), isA()); + expect(stored.global.hosts, isEmpty); + expect(stored.storeFor('bob'), isNull); + }); + + test('remove only affects the caller principal', () async { + final alice = await cluster.connectClient( + token: 'alice-token', + principal: 'alice', + ); + final bob = await cluster.connectClient( + token: 'bob-token', + principal: 'bob', + ); + await alice.driveCredentialAdd( + nodeId: 'web-01', + host: 'github.com', + credential: GitPat(token: 'A').toJson(), + ); + await bob.driveCredentialAdd( + nodeId: 'web-01', + host: 'github.com', + credential: GitPat(token: 'B').toJson(), + ); + + final rm = await alice.driveCredentialRemove( + nodeId: 'web-01', + host: 'github.com', + ); + expect(rm.ok, isTrue, reason: rm.message); + expect( + (await alice.driveCredentialList(nodeId: 'web-01')).entries, + isEmpty, + ); + + // bob's credential is untouched. + final bobList = await bob.driveCredentialList(nodeId: 'web-01'); + expect(bobList.entries.map((e) => e.host), contains('github.com')); + }); +} diff --git a/test/support/harness.dart b/test/support/harness.dart index c04719c..f97bf99 100644 --- a/test/support/harness.dart +++ b/test/support/harness.dart @@ -121,6 +121,7 @@ class TestCluster { Duration? autoDetachTimeout, Duration cleanupInterval = const Duration(minutes: 1), Clock? clock, + String? gitCredentialsHome, }) async { final node = NodeRuntime( NodeConfig( @@ -140,6 +141,7 @@ class TestCluster { autoDetachTimeout: autoDetachTimeout, cleanupInterval: cleanupInterval, clock: clock ?? const SystemClock(), + gitCredentialsHome: gitCredentialsHome, ), ); _nodes.add(node); diff --git a/test/unit/protocol/drive_credential_messages_test.dart b/test/unit/protocol/drive_credential_messages_test.dart new file mode 100644 index 0000000..9045f96 --- /dev/null +++ b/test/unit/protocol/drive_credential_messages_test.dart @@ -0,0 +1,85 @@ +import 'package:omnyshell/omnyshell.dart'; +import 'package:test/test.dart'; + +/// Round-trips the drive-credential RPC quartet through the frame codec. +void main() { + final codec = FrameCodec.standard(); + + test('DriveCredentialRequest (client→hub) round-trips', () { + final m = DriveCredentialRequest( + requestId: 'r1', + nodeId: 'web-01', + op: 'add', + host: 'github.com', + credential: const {'kind': 'pat', 'username': 'x', 'token': 't'}, + ); + final d = + codec.decodeControl(codec.encodeControl(m)).message + as DriveCredentialRequest; + expect(d.requestId, 'r1'); + expect(d.nodeId, 'web-01'); + expect(d.op, 'add'); + expect(d.host, 'github.com'); + expect(d.credential!['token'], 't'); + }); + + test( + 'NodeDriveCredentialRequest (hub→node) carries the stamped principal', + () { + final m = NodeDriveCredentialRequest( + requestId: 'r2', + principal: 'alice', + op: 'remove', + host: 'gitlab.com', + ); + final d = + codec.decodeControl(codec.encodeControl(m)).message + as NodeDriveCredentialRequest; + expect(d.principal, 'alice'); + expect(d.op, 'remove'); + expect(d.host, 'gitlab.com'); + expect(d.credential, isNull); + }, + ); + + test('responses round-trip masked entries and error messages', () { + final node = + codec + .decodeControl( + codec.encodeControl( + const NodeDriveCredentialResponse( + requestId: 'r3', + ok: true, + entries: [ + DriveCredentialEntry( + host: 'github.com', + description: 'GitPat(username: x, token: ***)', + ), + ], + ), + ), + ) + .message + as NodeDriveCredentialResponse; + expect(node.ok, isTrue); + expect(node.entries.single.host, 'github.com'); + expect(node.entries.single.description, contains('***')); + + final client = + codec + .decodeControl( + codec.encodeControl( + const DriveCredentialResponse( + requestId: 'r4', + ok: false, + message: 'Not authorized', + ), + ), + ) + .message + as DriveCredentialResponse; + expect(client.ok, isFalse); + expect(client.message, 'Not authorized'); + expect(client.entries, isEmpty); + }); +}