From a68a70fb7449b89e412f51a4d701685a8f2f0170 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 1 Jul 2026 21:15:12 -0300 Subject: [PATCH 1/5] Authenticate git drives to private remotes (global + per-principal) (1.50.0) Adopt omnydrive 1.10.0's git-credential feature so a node can authenticate to private git remotes itself, instead of relying on the node host's ambient git config. Git clone/push runs on the node, so credentials are node-local state managed by a new `omnyshell node git-credential add/list/remove` command and injected into the node's git operations (clone/fetch/push) at omnydrive's single GitCli chokepoint. GIT_TERMINAL_PROMPT=0 means a missing/wrong credential fails fast. Each 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 global: the hub-authenticated connecting principal's credential wins, and one principal never sees another's. Credentials live only on the node in ~/.omnyshell/git-credentials.json (mode 600) as a single backward-compatible structured file (a legacy flat file loads as the global scope). They are never sent to the hub/peers or serialized onto a mount/drive. The store is loaded fresh per drive session, so new credentials apply without a node restart. NOTE: temporarily depends on the omnydrive 1.10.0 branch via a git dependency_override (OmnyGrid/omnydrive#14); switch to `omnydrive: ^1.10.0` and drop the override once it is published to pub.dev. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 ++ README.md | 35 +++ bin/omnyshell.dart | 204 +++++++++++++++++- .../application/node/node_drive_service.dart | 34 ++- lib/src/application/node/node_runtime.dart | 16 ++ .../auth/node_git_credentials.dart | 134 ++++++++++++ lib/src/version.dart | 2 +- pubspec.yaml | 13 +- test/unit/node/node_git_credentials_test.dart | 149 +++++++++++++ 9 files changed, 596 insertions(+), 11 deletions(-) create mode 100644 lib/src/infrastructure/auth/node_git_credentials.dart create mode 100644 test/unit/node/node_git_credentials_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a7cd4e5..2614b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 1.50.0 + +### Added + +- **Git drives can now authenticate to private remotes.** Adopts omnydrive 1.10.0's + git-credential feature. A new `omnyshell node git-credential` command group manages + per-host credentials on the **node host** that performs the clone/push: + - `omnyshell node git-credential add (--pat | --username --password

| --ssh-key [--passphrase

]) [--principal

]` + - `omnyshell node git-credential list [--principal

]` (secrets masked) + - `omnyshell node git-credential remove [--principal

]` + Each 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 global**: the connecting (hub-authenticated) principal's + credential wins, falling back to the node's global one; one principal never sees + another's. Credentials are stored in `~/.omnyshell/git-credentials.json` (mode 600), + distinct from the client/hub auth `credentials.json`, and injected on the node's git + clone/fetch/push. They are **never** sent to the hub, peers, or serialized onto a + drive (`mounts.json`/`drives.json`). The store is loaded fresh per drive session, so + newly added credentials take effect without a node restart. + ## 1.49.0 ### Added diff --git a/README.md b/README.md index 70f39e1..045c89e 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,41 @@ omnyshell drive mount --git https://github.com/acme/app.git \ worker-prod-01:/srv/app --branch main ``` +**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: + +```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 +``` + +A credential is either **global** (node-wide) or scoped to a connecting +**principal** with `--principal

`: + +```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 +``` + +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. + +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.) + Restrict which sub-paths a directory mount serves with repeatable `--include` (whitelist) / `--exclude` (wins over include) globs — the filter is baked into the mount, so every sync keeps applying it. With **neither** flag given, the directory's diff --git a/bin/omnyshell.dart b/bin/omnyshell.dart index 77e6db3..795f483 100644 --- a/bin/omnyshell.dart +++ b/bin/omnyshell.dart @@ -8,7 +8,16 @@ import 'package:cryptography/cryptography.dart'; import 'package:dart_service_manager/dart_service_manager.dart' as svc; import 'package:http/http.dart' as http; import 'package:omnydrive/omnydrive.dart' - show PathFilter, SyncDirection, loadOmnyIgnore, omnyIgnoreFileName; + show + GitCredential, + GitCredentialStore, + GitPat, + GitSshKey, + GitUserPass, + PathFilter, + SyncDirection, + loadOmnyIgnore, + omnyIgnoreFileName; import 'package:path/path.dart' as p; import 'package:omnyshell/omnyshell_client.dart'; import 'package:omnyshell/omnyshell_hub.dart'; @@ -17,6 +26,7 @@ import 'package:omnyshell/src/application/client/drive/workspace_layout.dart'; import 'package:omnyshell/src/application/client/ide/tui/terminal.dart' show Terminal; import 'package:omnyshell/src/domain/entities/platform_info_io.dart'; +import 'package:omnyshell/src/infrastructure/auth/node_git_credentials.dart'; import 'package:omnyshell/src/infrastructure/identity/certificate_names.dart'; import 'package:omnyshell/src/infrastructure/tls/ca_pinning.dart'; @@ -1650,6 +1660,7 @@ class NodeCommand extends Command { NodeCommand() { addSubcommand(NodeStartCommand()); addSubcommand(NodeProfileCommand()); + addSubcommand(NodeGitCredentialCommand()); } @override @@ -1659,6 +1670,197 @@ 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. +/// +/// 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()); + } + + @override + String get name => 'git-credential'; + + @override + String get description => + 'Manage git credentials this node uses to reach private remotes.'; +} + +/// 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).', +); + +class NodeGitCredentialAddCommand extends Command { + NodeGitCredentialAddCommand() { + argParser + ..addOption('pat', help: 'HTTPS personal access token.') + ..addOption( + 'username', + help: 'Username (with --password, or to override the PAT username).', + ) + ..addOption('password', help: 'HTTPS password (with --username).') + ..addOption('ssh-key', help: 'Path to an SSH private key.') + ..addOption( + 'passphrase', + help: 'SSH key passphrase (requires an ssh-agent to take effect).', + ); + _addPrincipalOption(argParser); + } + + @override + String get name => 'add'; + + @override + String get description => + 'Store a credential for a git host (e.g. github.com) on this node.'; + + @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', + ]); + + @override + Future run() async { + final args = argResults!; + final rest = args.rest; + if (rest.isEmpty) { + throw _CliError( + 'Usage: omnyshell node git-credential add ' + '(--pat | --username --password

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

]', + ); + } + 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 creds = await NodeGitCredentials.load(); + creds.scopeFor(principal: principal).put(host, credential); + await creds.save(); + stdout.writeln('Stored $credential for $host${_scopeSuffix(principal)}.'); + } +} + +class NodeGitCredentialListCommand extends Command { + NodeGitCredentialListCommand() { + _addPrincipalOption(argParser); + } + + @override + String get name => 'list'; + + @override + String get description => + 'List git credentials stored on this node (secrets masked).'; + + @override + Future run() async { + final principal = argResults!['principal'] as String?; + final creds = await NodeGitCredentials.load(); + + // Scoped view: only the requested principal. + if (principal != null) { + final store = creds.storeFor(principal); + if (store == null || store.hosts.isEmpty) { + stdout.writeln('No git credentials for principal $principal.'); + return; + } + _printStore(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) { + stdout.writeln('No git credentials.'); + return; + } + if (hasGlobal) { + stdout.writeln('[global]'); + _printStore(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)}'); + } + } +} + +class NodeGitCredentialRemoveCommand extends Command { + NodeGitCredentialRemoveCommand() { + _addPrincipalOption(argParser); + } + + @override + String get name => 'remove'; + + @override + String get description => 'Remove the stored credential for a git host.'; + + @override + Future run() async { + final args = argResults!; + final rest = args.rest; + if (rest.isEmpty) { + throw _CliError( + 'Usage: omnyshell node git-credential remove [--principal

]', + ); + } + final host = rest.first; + final principal = args['principal'] as String?; + final creds = await NodeGitCredentials.load(); + final store = principal == null ? creds.global : creds.storeFor(principal); + if (store == null || !store.remove(host)) { + throw _CliError( + 'No credential stored for $host${_scopeSuffix(principal)}.', + ); + } + await creds.save(); + stdout.writeln('Removed credential for $host${_scopeSuffix(principal)}.'); + } +} + +/// 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) { diff --git a/lib/src/application/node/node_drive_service.dart b/lib/src/application/node/node_drive_service.dart index 103742f..4ba1a90 100644 --- a/lib/src/application/node/node_drive_service.dart +++ b/lib/src/application/node/node_drive_service.dart @@ -43,6 +43,15 @@ class NodeDriveService { /// Called once the session ends so the runtime can forget the channel. final Future Function() onClose; + /// The git CLI used for git-drive operations. Injectable for testing. + final GitCli git; + + /// Resolves the git credential (if any) for a private remote, keyed by host. + /// Null (or a null resolution) means git falls back to the node host's own + /// configuration. Credentials live only on the node and are never sent to the + /// hub, peers, or serialized onto a [Drive]. + final GitCredentialResolver? gitCredentials; + final DriveFrameReader _reader = DriveFrameReader(); final _done = Completer(); Future _chain = Future.value(); @@ -65,6 +74,8 @@ class NodeDriveService { required this.onClose, this.clock = const SystemClock(), this.log, + this.git = const GitCli(), + this.gitCredentials, }); /// Serves the mount until the channel closes. @@ -166,7 +177,7 @@ class NodeDriveService { case DriveOp.gitSync: _reply(await _gitSync(id, msg.header)); case DriveOp.gitHead: - final head = await const GitCli().revParse(_root); + final head = await git.revParse(_root); _reply(DriveMessage.ok(id, fields: {'head': head})); default: _reply(DriveMessage.err(id, 'unknown op: ${msg.op}')); @@ -219,7 +230,7 @@ class NodeDriveService { Future _gitClone(int id, Map h) async { final url = h['url'] as String; final origin = OriginUri(url); - final git = const GitCli(); + final credential = gitCredentials?.resolve(origin); // A populated, non-empty destination means a prior clone — reuse it. final dir = Directory(_root); final exists = await dir.exists() && !(await dir.list().isEmpty); @@ -232,19 +243,27 @@ class NodeDriveService { branch: h['branch'] as String?, depth: (h['depth'] as num?)?.toInt(), bare: h['bare'] == true, + credential: credential, ); } - _gitDrive = await GitProvider(endpoint: EndpointId(endpointId)).describe( - origin, - accessMode: _readWrite ? AccessMode.readWrite : AccessMode.readOnly, - ); + _gitDrive = + await GitProvider( + endpoint: EndpointId(endpointId), + credentials: gitCredentials, + ).describe( + origin, + accessMode: _readWrite ? AccessMode.readWrite : AccessMode.readOnly, + ); final head = await git.revParse(_root); return DriveMessage.ok(id, fields: {'head': head}); } Future _gitSync(int id, Map h) async { final drive = _gitDrive ??= - await GitProvider(endpoint: EndpointId(endpointId)).describe( + await GitProvider( + endpoint: EndpointId(endpointId), + credentials: gitCredentials, + ).describe( OriginUri(h['url'] as String), accessMode: _readWrite ? AccessMode.readWrite : AccessMode.readOnly, ); @@ -263,6 +282,7 @@ class NodeDriveService { ); final sync = GitProvider( endpoint: EndpointId(endpointId), + credentials: gitCredentials, ).synchronizer(drive); try { final plan = await sync.plan( diff --git a/lib/src/application/node/node_runtime.dart b/lib/src/application/node/node_runtime.dart index 186bc01..894a3e7 100644 --- a/lib/src/application/node/node_runtime.dart +++ b/lib/src/application/node/node_runtime.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:omnydrive/omnydrive.dart' show GitCredentialResolver; + import '../../domain/backend/shell_backend.dart'; import '../../domain/backend/shell_request.dart'; import '../../domain/backend/shell_session.dart'; @@ -13,6 +15,7 @@ import '../../domain/entities/session.dart'; import '../../domain/value_objects/node_id.dart'; import '../../domain/value_objects/omny_uid.dart'; import '../../infrastructure/auth/credential_provider.dart'; +import '../../infrastructure/auth/node_git_credentials.dart'; import '../../infrastructure/identity/machine_id.dart'; import '../../infrastructure/identity/uid_computer.dart'; import '../../infrastructure/backend/process_inspector.dart'; @@ -421,6 +424,18 @@ class NodeRuntime { NodeSessionOpened(channel: open.channel, sessionId: open.sessionId), ), ); + // Load the node's git credentials fresh per drive session so newly added + // credentials take effect without a node restart, and scope them to the + // (hub-authenticated) connecting principal: their credential for a host + // wins, falling back to the node's global credential. A malformed file + // must never block a session, so failures degrade to no credentials. + GitCredentialResolver? gitCreds; + try { + final creds = await NodeGitCredentials.load(); + gitCreds = creds.resolverFor(open.principal); + } on Object catch (e) { + config.logger?.call('[drive] ignoring git credentials: $e'); + } unawaited( NodeDriveService( channel: channel, @@ -428,6 +443,7 @@ class NodeRuntime { endpointId: _driveEndpointId(), clock: config.clock, log: config.logger, + gitCredentials: gitCreds, onClose: () => mux.closeChannel(open.channel), ).run(), ); diff --git a/lib/src/infrastructure/auth/node_git_credentials.dart b/lib/src/infrastructure/auth/node_git_credentials.dart new file mode 100644 index 0000000..ce97ea5 --- /dev/null +++ b/lib/src/infrastructure/auth/node_git_credentials.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:omnydrive/omnydrive.dart' + show GitCredential, GitCredentialResolver, GitCredentialStore, OriginUri; + +import '../../shared/utils/omnyshell_home.dart'; + +/// The node-local git-credential file. Kept distinct from the client/hub auth +/// store (`credentials.json`) so the two never collide, even though both live in +/// `~/.omnyshell/`. +const String gitCredentialsFileName = 'git-credentials.json'; + +/// Node-host git credentials for reaching private remotes, split into a +/// **global** (node-wide) scope and optional **per-principal** scopes. +/// +/// Git clone/push runs on the node, so credentials are node-local state. A drive +/// session carries the authenticated principal ([resolverFor]); credentials +/// resolve **principal-first, then global**: the connecting principal's +/// credential for a host wins, falling back to the node's global credential when +/// the principal has none. One principal never sees another's credential. +/// +/// Persisted at `~/.omnyshell/git-credentials.json` (mode 600) as: +/// ```json +/// { "credentials": { "": { .. } }, // global +/// "principals": { "": { "credentials": { "": { .. } } } } } +/// ``` +/// The `principals` key is optional, so a legacy flat file (global only) loads +/// unchanged. Principals are JSON keys, so arbitrary identity strings (`@`, `/`, +/// unicode) are safe — no filename sanitization is involved. Each scope reuses +/// omnydrive's host-keyed [GitCredentialStore]; only the outer structure and +/// file I/O live here. +class NodeGitCredentials { + final GitCredentialStore _global; + final Map _byPrincipal; + + NodeGitCredentials._(this._global, this._byPrincipal); + + /// An empty set of credentials. + factory NodeGitCredentials.empty() => NodeGitCredentials._( + GitCredentialStore(), + {}, + ); + + /// The global (node-wide) credential scope. + GitCredentialStore get global => _global; + + /// The principals that currently have at least one credential. + List get principals => [ + for (final e in _byPrincipal.entries) + if (e.value.hosts.isNotEmpty) e.key, + ]..sort(); + + /// The credential store for [principal], or null if none exists. + GitCredentialStore? storeFor(String principal) => _byPrincipal[principal]; + + /// The scope the CLI mutates: the global store when [principal] is null, else + /// [principal]'s store (created on demand so `put` can target it). + GitCredentialStore scopeFor({String? principal}) => principal == null + ? _global + : _byPrincipal.putIfAbsent(principal, GitCredentialStore.new); + + /// A resolver for a connecting [principal]: principal-first, global fallback. + GitCredentialResolver resolverFor(String principal) => + _PrincipalFirstResolver(_byPrincipal[principal], _global); + + static String path({String? home}) => + omnyshellPath([gitCredentialsFileName], home: home); + + /// Loads the credentials, returning an empty set when no file exists yet. + static Future load({String? home}) async { + final file = File(path(home: home)); + if (!await file.exists()) return NodeGitCredentials.empty(); + final json = jsonDecode(await file.readAsString()) as Map; + // The top level is itself a `{credentials: {...}}` map — the global scope. + final global = GitCredentialStore.fromJson(json); + final byPrincipal = {}; + final rawPrincipals = json['principals']; + if (rawPrincipals is Map) { + rawPrincipals.forEach((principal, value) { + byPrincipal[principal.toString()] = GitCredentialStore.fromJson( + value as Map, + ); + }); + } + return NodeGitCredentials._(global, byPrincipal); + } + + /// Persists to `~/.omnyshell/git-credentials.json`, creating `~/.omnyshell` + /// (mode 700) and tightening the file to mode 600 on POSIX. Empty principal + /// scopes are pruned so removing the last credential leaves no stray entry. + Future save({String? home}) async { + final file = File(path(home: home)); + final dir = file.parent; + if (!await dir.exists()) { + await dir.create(recursive: true); + await _chmod(dir.path, '700'); + } + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(toJson()), + ); + await _chmod(file.path, '600'); + } + + Map toJson() { + final principals = { + for (final e in _byPrincipal.entries) + if (e.value.hosts.isNotEmpty) e.key: e.value.toJson(), + }; + return { + ..._global.toJson(), // -> "credentials": { ... } + if (principals.isNotEmpty) 'principals': principals, + }; + } + + static Future _chmod(String path, String mode) async { + if (Platform.isWindows) return; + await Process.run('chmod', [mode, path]); + } +} + +/// Resolves a host credential from a principal's scope first, then the global +/// scope. A null [_principal] scope (principal has no credentials at all) simply +/// falls through to global. +class _PrincipalFirstResolver implements GitCredentialResolver { + final GitCredentialStore? _principal; + final GitCredentialStore _global; + + _PrincipalFirstResolver(this._principal, this._global); + + @override + GitCredential? resolve(OriginUri origin) => + _principal?.resolve(origin) ?? _global.resolve(origin); +} diff --git a/lib/src/version.dart b/lib/src/version.dart index 9b64859..cdb7002 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.49.0'; +const String omnyShellVersion = '1.50.0'; diff --git a/pubspec.yaml b/pubspec.yaml index 80ea681..e23df4d 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.49.0 +version: 1.50.0 repository: https://github.com/OmnyGrid/omnyshell environment: @@ -22,7 +22,7 @@ dependencies: ffi: ^2.1.0 http: ^1.2.0 meta: ^1.18.3 - omnydrive: ^1.8.0 + omnydrive: ^1.10.0 path: ^1.9.0 pointycastle: ^4.0.0 #portable_pty: ^0.0.5 # waiting crash fix. @@ -39,6 +39,15 @@ dev_dependencies: lints: ^6.1.0 test: ^1.31.1 +# TEMPORARY: build against the omnydrive 1.10.0 branch until it is published to +# pub.dev. Remove this block once `omnydrive: ^1.10.0` resolves from the hosted +# registry (tracking: OmnyGrid/omnydrive#14). +dependency_overrides: + omnydrive: + git: + url: https://github.com/OmnyGrid/omnydrive.git + ref: feat/public-git-credential-store + # The only "secret" in the tree is a throwaway self-signed certificate/key used # by the test harness to exercise real wss connections on localhost. false_secrets: diff --git a/test/unit/node/node_git_credentials_test.dart b/test/unit/node/node_git_credentials_test.dart new file mode 100644 index 0000000..5443c95 --- /dev/null +++ b/test/unit/node/node_git_credentials_test.dart @@ -0,0 +1,149 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:omnydrive/omnydrive.dart' show GitPat, GitUserPass, OriginUri; +import 'package:omnyshell/src/infrastructure/auth/node_git_credentials.dart'; +import 'package:test/test.dart'; + +/// Covers omnyshell's node-side git credentials: the global + per-principal +/// split, principal-first resolution with global fallback, backward-compat with +/// the legacy flat file, and the on-disk contract (distinct filename, mode 600). +/// The mechanics of injecting a credential into git live in omnydrive's tests. +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('omnyshell-gitcreds')); + tearDown(() => tmp.deleteSync(recursive: true)); + + String file() => '${tmp.path}/.omnyshell/git-credentials.json'; + Future load() => NodeGitCredentials.load(home: tmp.path); + + test('load returns empty when no file exists', () async { + final creds = await load(); + expect(creds.global.hosts, isEmpty); + expect(creds.principals, isEmpty); + }); + + test('global and per-principal credentials round-trip', () async { + final creds = NodeGitCredentials.empty(); + creds.scopeFor().put('github.com', GitPat(token: 'global')); + creds + .scopeFor(principal: 'alice') + .put('github.com', GitPat(token: 'alice')); + creds + .scopeFor(principal: 'bob') + .put('gitlab.com', GitUserPass(username: 'bob', password: 'pw')); + await creds.save(home: tmp.path); + + final reloaded = await load(); + expect(reloaded.global.hosts, ['github.com']); + expect(reloaded.principals, ['alice', 'bob']); + expect(reloaded.storeFor('alice')!.get('github.com'), isA()); + expect(reloaded.storeFor('bob')!.get('gitlab.com'), isA()); + }); + + group('resolverFor (principal-first, global fallback)', () { + late NodeGitCredentials creds; + setUp(() { + creds = NodeGitCredentials.empty(); + creds.scopeFor().put('github.com', GitPat(token: 'GLOBAL')); + creds + .scopeFor(principal: 'alice') + .put('github.com', GitPat(token: 'ALICE')); + }); + + GitPat? patFor(String principal, String url) => + creds.resolverFor(principal).resolve(OriginUri(url)) as GitPat?; + + test("a principal's own credential wins over the global one", () { + expect(patFor('alice', 'https://github.com/x/y.git')!.token, 'ALICE'); + }); + + test('a principal with no host entry falls back to global', () { + // bob has no credentials at all. + expect(patFor('bob', 'https://github.com/x/y.git')!.token, 'GLOBAL'); + }); + + test('principal B never receives principal A\'s credential', () { + creds.scopeFor(principal: 'bob').put('gitlab.com', GitPat(token: 'BOB')); + // bob asking about github.com must NOT get ALICE; falls back to GLOBAL. + expect(patFor('bob', 'https://github.com/x/y.git')!.token, 'GLOBAL'); + }); + + test('no credential for the host resolves to null', () { + expect(patFor('alice', 'https://bitbucket.org/x/y.git'), isNull); + }); + }); + + test('a legacy flat file loads as the global scope', () async { + // The format shipped before per-principal support: a bare host map. + Directory('${tmp.path}/.omnyshell').createSync(recursive: true); + File(file()).writeAsStringSync( + jsonEncode({ + 'credentials': { + 'github.com': { + 'kind': 'pat', + 'username': 'x-access-token', + 'token': 't', + }, + }, + }), + ); + + final creds = await load(); + expect(creds.global.get('github.com'), isA()); + expect(creds.principals, isEmpty); + }); + + test('arbitrary principal strings survive as keys', () async { + const weird = ['alice@corp.com', 'team/infra', 'ünïcode']; + final creds = NodeGitCredentials.empty(); + for (final p in weird) { + creds.scopeFor(principal: p).put('github.com', GitPat(token: p)); + } + await creds.save(home: tmp.path); + + final reloaded = await load(); + expect(reloaded.principals, unorderedEquals(weird)); + for (final p in weird) { + expect((reloaded.storeFor(p)!.get('github.com')! as GitPat).token, p); + } + }); + + test('saving prunes an emptied principal scope', () async { + final creds = NodeGitCredentials.empty(); + creds.scopeFor(principal: 'alice').put('github.com', GitPat(token: 't')); + await creds.save(home: tmp.path); + + final reloaded = await load(); + expect(reloaded.storeFor('alice')!.remove('github.com'), isTrue); + await reloaded.save(home: tmp.path); + + expect((await load()).principals, isEmpty); + }); + + test( + 'writes git-credentials.json (not the auth credentials.json), mode 600', + () async { + final creds = NodeGitCredentials.empty(); + creds.scopeFor().put('github.com', GitPat(token: 't')); + await creds.save(home: tmp.path); + + expect(File(file()).existsSync(), isTrue); + expect( + File('${tmp.path}/.omnyshell/credentials.json').existsSync(), + isFalse, + ); + }, + ); + + test('the store file is owner-only (0600) on POSIX', () async { + final creds = NodeGitCredentials.empty(); + creds.scopeFor().put('github.com', GitPat(token: 't')); + await creds.save(home: tmp.path); + final mode = File(file()).statSync().mode & 0x1FF; + expect(mode, 0x180); // rw------- == 0600 + }, testOn: '!windows'); +} From 37590be85b8156e35b27908118af57c7d9a16d5a Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 1 Jul 2026 21:39:37 -0300 Subject: [PATCH 2/5] Depend on published omnydrive 1.10.0, drop git override omnydrive 1.10.0 is now on pub.dev, so resolve it from the hosted registry and remove the temporary git dependency_override. Co-Authored-By: Claude Opus 4.8 (1M context) --- pubspec.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index e23df4d..6b2202b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,15 +39,6 @@ dev_dependencies: lints: ^6.1.0 test: ^1.31.1 -# TEMPORARY: build against the omnydrive 1.10.0 branch until it is published to -# pub.dev. Remove this block once `omnydrive: ^1.10.0` resolves from the hosted -# registry (tracking: OmnyGrid/omnydrive#14). -dependency_overrides: - omnydrive: - git: - url: https://github.com/OmnyGrid/omnydrive.git - ref: feat/public-git-credential-store - # The only "secret" in the tree is a throwaway self-signed certificate/key used # by the test harness to exercise real wss connections on localhost. false_secrets: From dae4cc1c04eb8aa09156886355f2dd65255e3118 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 1 Jul 2026 21:54:09 -0300 Subject: [PATCH 3/5] Test per-host credential resolution (principal vs global) Prove that a single resolver picks per host: one host resolves to the connecting principal's credential while another falls back to the node's global credential. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/unit/node/node_git_credentials_test.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/unit/node/node_git_credentials_test.dart b/test/unit/node/node_git_credentials_test.dart index 5443c95..708ee7f 100644 --- a/test/unit/node/node_git_credentials_test.dart +++ b/test/unit/node/node_git_credentials_test.dart @@ -75,6 +75,17 @@ void main() { test('no credential for the host resolves to null', () { expect(patFor('alice', 'https://bitbucket.org/x/y.git'), isNull); }); + + test('resolves per host: principal for one, global for another', () { + // alice has her own github.com, but nothing for gitlab.com; the global + // scope has gitlab.com. One resolver, two hosts, two different sources. + creds.scopeFor().put('gitlab.com', GitPat(token: 'GLOBAL-GITLAB')); + expect(patFor('alice', 'https://github.com/x/y.git')!.token, 'ALICE'); + expect( + patFor('alice', 'https://gitlab.com/x/y.git')!.token, + 'GLOBAL-GITLAB', + ); + }); }); test('a legacy flat file loads as the global scope', () async { From cd1f2a421ad8b4e8d225530018536e713b97584c Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 1 Jul 2026 22:04:32 -0300 Subject: [PATCH 4/5] Add integration test: git drive clone with a private credential Clone a private remote end-to-end through the real node path (NodeGitCredentials -> resolverFor(principal) -> omnydrive GitProvider). The remote is a local smart-HTTP git server (git http-backend) behind HTTP Basic auth, so a clone only succeeds when the credential is injected and accepted. Covers the principal way (a principal-scoped credential authenticates; a different principal without one is rejected), the global way (a node-wide credential serves any principal), per-host resolution, and a no-credential case proving the remote truly requires auth. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../git_credential_drive_test.dart | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 test/integration/git_credential_drive_test.dart diff --git a/test/integration/git_credential_drive_test.dart b/test/integration/git_credential_drive_test.dart new file mode 100644 index 0000000..99bdccd --- /dev/null +++ b/test/integration/git_credential_drive_test.dart @@ -0,0 +1,264 @@ +@TestOn('vm && !windows') +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:omnydrive/omnydrive.dart' + show + AccessMode, + EndpointId, + GitCredentialResolver, + GitProvider, + GitUserPass, + LocalPath, + MountType, + OriginUri; +import 'package:omnyshell/src/infrastructure/auth/node_git_credentials.dart'; +import 'package:test/test.dart'; + +/// End-to-end: a git drive cloning a **private** remote using a stored +/// credential, exercising the real node path +/// (`NodeGitCredentials` → `resolverFor(principal)` → omnydrive `GitProvider`). +/// +/// The remote is a local smart-HTTP git server (`git http-backend`) gated behind +/// HTTP Basic auth, so the clone only succeeds when the credential is actually +/// injected and accepted — proving both the **principal way** and the +/// **global way** resolve and authenticate, and that a missing credential fails. +void main() { + const username = 'git-user'; + const password = 's3cr3t-token'; + const secret = 'top secret repository contents\n'; + + late Directory tmp; + late _AuthGitHttpServer server; + late String url; + + setUp(() async { + tmp = Directory.systemTemp.createTempSync('omnyshell-gitcred-it'); + + // A bare repo with one commit, served over authenticated HTTP. + final serveRoot = Directory('${tmp.path}/serve') + ..createSync(recursive: true); + final work = '${tmp.path}/work'; + await _git(['init', '-q', '-b', 'main', work]); + File('$work/README.md').writeAsStringSync(secret); + await _git(['-C', work, 'add', '.']); + await _git([ + '-C', + work, + '-c', + 'user.email=t@example.com', + '-c', + 'user.name=Test', + 'commit', + '-q', + '-m', + 'init', + ]); + await _git(['clone', '-q', '--bare', work, '${serveRoot.path}/repo.git']); + + server = await _AuthGitHttpServer.start( + projectRoot: serveRoot.path, + username: username, + password: password, + ); + // OriginUri host is 127.0.0.1, so credentials are keyed on that host. + url = 'http://127.0.0.1:${server.port}/repo.git'; + }); + + tearDown(() async { + await server.close(); + tmp.deleteSync(recursive: true); + }); + + /// Clones [url] into a fresh dir via the drive's GitProvider using [resolver], + /// and returns the cloned working copy's README contents. + Future cloneWith(GitCredentialResolver? resolver, String name) async { + final provider = GitProvider( + endpoint: EndpointId('test-node'), + credentials: resolver, + ); + final drive = await provider.describe( + OriginUri(url), + accessMode: AccessMode.readOnly, + ); + final dest = '${tmp.path}/mount-$name'; + await provider.materialize( + drive: drive, + dest: LocalPath(dest), + mountType: MountType.mirror, + ); + return File('$dest/README.md').readAsStringSync(); + } + + GitUserPass validCredential() => + GitUserPass(username: username, password: password); + + test( + 'the private way: a principal-scoped credential clones the remote', + () async { + final creds = NodeGitCredentials.empty(); + creds.scopeFor(principal: 'alice').put('127.0.0.1', validCredential()); + + // alice authenticates with her own credential. + expect(await cloneWith(creds.resolverFor('alice'), 'alice'), secret); + + // bob has no credential (and there is no global one) → clone is rejected. + await expectLater( + cloneWith(creds.resolverFor('bob'), 'bob'), + throwsA(anything), + ); + }, + ); + + test( + 'the global way: a node-wide credential clones for any principal', + () async { + final creds = NodeGitCredentials.empty(); + creds.scopeFor().put('127.0.0.1', validCredential()); // global scope + + // Any principal falls back to the global credential. + expect(await cloneWith(creds.resolverFor('anyone'), 'global'), secret); + }, + ); + + test('principal credential wins, but other hosts use the global one', () async { + // Proves per-host resolution end-to-end: the served host is only registered + // globally, while the principal has an (irrelevant) credential elsewhere. + final creds = NodeGitCredentials.empty(); + creds.scopeFor().put('127.0.0.1', validCredential()); + creds + .scopeFor(principal: 'alice') + .put('github.com', GitUserPass(username: 'x', password: 'y')); + + expect(await cloneWith(creds.resolverFor('alice'), 'mixed'), secret); + }); + + test('no credential fails — the remote really requires auth', () async { + final creds = NodeGitCredentials.empty(); + await expectLater( + cloneWith(creds.resolverFor('anyone'), 'none'), + throwsA(anything), + ); + }); +} + +Future _git(List args) async { + final r = await Process.run('git', args); + if (r.exitCode != 0) { + throw StateError( + 'git ${args.join(' ')} failed (${r.exitCode}): ${r.stderr}', + ); + } +} + +/// A minimal smart-HTTP git server that enforces HTTP Basic auth and delegates +/// to `git http-backend` (CGI). Only requests carrying the expected +/// `Authorization: Basic ` header are served; others get 401. +class _AuthGitHttpServer { + final HttpServer _server; + final String _projectRoot; + final String _expectedAuth; + + _AuthGitHttpServer(this._server, this._projectRoot, this._expectedAuth) { + _server.listen(_handle); + } + + int get port => _server.port; + Future close() => _server.close(force: true); + + static Future<_AuthGitHttpServer> start({ + required String projectRoot, + required String username, + required String password, + }) async { + final expected = + 'Basic ${base64.encode(utf8.encode('$username:$password'))}'; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + return _AuthGitHttpServer(server, projectRoot, expected); + } + + Future _handle(HttpRequest req) async { + final res = req.response; + if (req.headers.value(HttpHeaders.authorizationHeader) != _expectedAuth) { + res + ..statusCode = HttpStatus.unauthorized + ..headers.set(HttpHeaders.wwwAuthenticateHeader, 'Basic realm="git"'); + await res.close(); + return; + } + + final body = await _readAll(req); + final env = { + 'GIT_HTTP_EXPORT_ALL': '1', + 'GIT_PROJECT_ROOT': _projectRoot, + 'REQUEST_METHOD': req.method, + 'PATH_INFO': req.uri.path, + 'QUERY_STRING': req.uri.query, + 'CONTENT_TYPE': req.headers.contentType?.toString() ?? '', + 'CONTENT_LENGTH': '${body.length}', + 'REMOTE_USER': 'tester', + }; + // Forward the wire-protocol version so smart HTTP (v2) negotiates correctly. + final proto = req.headers.value('git-protocol'); + if (proto != null) env['GIT_PROTOCOL'] = proto; + + final proc = await Process.start('git', ['http-backend'], environment: env); + proc.stdin.add(body); + await proc.stdin.close(); + final out = await _readAll(proc.stdout); + await proc.stderr.drain(); + await proc.exitCode; + + _writeCgiResponse(res, out); + await res.close(); + } + + /// Splits the CGI output into headers + body, applies the `Status:`/header + /// lines to [res], and writes the body. + void _writeCgiResponse(HttpResponse res, List out) { + final sep = _headerBodyBoundary(out); + final headerBytes = sep.index < 0 ? out : out.sublist(0, sep.index); + final bodyBytes = sep.index < 0 ? const [] : out.sublist(sep.end); + + for (final line in utf8.decode(headerBytes).split(RegExp(r'\r?\n'))) { + if (line.isEmpty) continue; + final colon = line.indexOf(':'); + if (colon < 0) continue; + final name = line.substring(0, colon).trim(); + final value = line.substring(colon + 1).trim(); + if (name.toLowerCase() == 'status') { + res.statusCode = int.tryParse(value.split(' ').first) ?? 200; + } else { + res.headers.set(name, value); + } + } + res.add(bodyBytes); + } + + /// Finds the `\r\n\r\n` (or `\n\n`) that separates CGI headers from the body. + ({int index, int end}) _headerBodyBoundary(List out) { + for (var i = 0; i + 3 < out.length; i++) { + if (out[i] == 13 && + out[i + 1] == 10 && + out[i + 2] == 13 && + out[i + 3] == 10) { + return (index: i, end: i + 4); + } + } + for (var i = 0; i + 1 < out.length; i++) { + if (out[i] == 10 && out[i + 1] == 10) return (index: i, end: i + 2); + } + return (index: -1, end: -1); + } + + static Future> _readAll(Stream> s) async { + final b = BytesBuilder(copy: false); + await for (final chunk in s) { + b.add(chunk); + } + return b.takeBytes(); + } +} From e62812b6b2bfe92c635b4432ed857e00cbdc7f61 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Wed, 1 Jul 2026 22:11:11 -0300 Subject: [PATCH 5/5] Test global-credential fallback for a principal on an unregistered host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an integration case: a global credential covers the served host (host-x) while principal A has a credential only for a different host (host-y). Cloning host-x as A must resolve the GLOBAL credential — A's host-y credential must not be consulted. Asserts both the resolved credential and a successful clone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../git_credential_drive_test.dart | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/test/integration/git_credential_drive_test.dart b/test/integration/git_credential_drive_test.dart index 99bdccd..3f18132 100644 --- a/test/integration/git_credential_drive_test.dart +++ b/test/integration/git_credential_drive_test.dart @@ -124,17 +124,30 @@ void main() { }, ); - test('principal credential wins, but other hosts use the global one', () async { - // Proves per-host resolution end-to-end: the served host is only registered - // globally, while the principal has an (irrelevant) credential elsewhere. - final creds = NodeGitCredentials.empty(); - creds.scopeFor().put('127.0.0.1', validCredential()); - creds - .scopeFor(principal: 'alice') - .put('github.com', GitUserPass(username: 'x', password: 'y')); + test( + 'principal falls back to the global credential for an unregistered host', + () async { + // host-x is the served repo (127.0.0.1), registered only GLOBALLY. + // host-y is a different host for which principal A has its own credential. + final creds = NodeGitCredentials.empty(); + creds.scopeFor().put('127.0.0.1', validCredential()); // global, host-x + creds + .scopeFor(principal: 'alice') + .put( + 'host-y.example', + GitUserPass(username: 'host-y-user', password: 'host-y-pass'), + ); - expect(await cloneWith(creds.resolverFor('alice'), 'mixed'), secret); - }); + // Cloning host-x as alice must use the GLOBAL credential — alice's only + // credential is for host-y and must not be consulted for host-x. + final resolved = + creds.resolverFor('alice').resolve(OriginUri(url)) as GitUserPass; + expect(resolved.username, username); // the global (host-x) credential + expect(resolved.password, password); + + expect(await cloneWith(creds.resolverFor('alice'), 'fallback'), secret); + }, + ); test('no credential fails — the remote really requires auth', () async { final creds = NodeGitCredentials.empty();