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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <host> (--pat <t> | --username <u> --password <p> | --ssh-key <path> [--passphrase <p>]) [--principal <p>]`
- `omnyshell node git-credential list [--principal <p>]` (secrets masked)
- `omnyshell node git-credential remove <host> [--principal <p>]`
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
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token> # HTTPS PAT
omnyshell node git-credential add gitlab.com --username me --password <pw>
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 <p>`:

```sh
omnyshell node git-credential add github.com --pat <token> # global
omnyshell node git-credential add github.com --pat <alice-token> --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
Expand Down
204 changes: 203 additions & 1 deletion bin/omnyshell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -1650,6 +1660,7 @@ class NodeCommand extends Command<void> {
NodeCommand() {
addSubcommand(NodeStartCommand());
addSubcommand(NodeProfileCommand());
addSubcommand(NodeGitCredentialCommand());
}

@override
Expand All @@ -1659,6 +1670,197 @@ class NodeCommand extends Command<void> {
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 <p>`. At mount time the node resolves a host's
/// credential principal-first, then falls back to the global one.
class NodeGitCredentialCommand extends Command<void> {
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<void> {
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 <token>',
'omnyshell node git-credential add gitlab.com --username me --password <pw>',
'omnyshell node git-credential add github.com --ssh-key ~/.ssh/id_ed25519',
'omnyshell node git-credential add github.com --pat <t> --principal alice',
]);

@override
Future<void> run() async {
final args = argResults!;
final rest = args.rest;
if (rest.isEmpty) {
throw _CliError(
'Usage: omnyshell node git-credential add <host> '
'(--pat <t> | --username <u> --password <p> | --ssh-key <path>) '
'[--principal <p>]',
);
}
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<void> {
NodeGitCredentialListCommand() {
_addPrincipalOption(argParser);
}

@override
String get name => 'list';

@override
String get description =>
'List git credentials stored on this node (secrets masked).';

@override
Future<void> 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<void> {
NodeGitCredentialRemoveCommand() {
_addPrincipalOption(argParser);
}

@override
String get name => 'remove';

@override
String get description => 'Remove the stored credential for a git host.';

@override
Future<void> run() async {
final args = argResults!;
final rest = args.rest;
if (rest.isEmpty) {
throw _CliError(
'Usage: omnyshell node git-credential remove <host> [--principal <p>]',
);
}
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) {
Expand Down
34 changes: 27 additions & 7 deletions lib/src/application/node/node_drive_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ class NodeDriveService {
/// Called once the session ends so the runtime can forget the channel.
final Future<void> 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<void>();
Future<void> _chain = Future.value();
Expand All @@ -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.
Expand Down Expand Up @@ -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}'));
Expand Down Expand Up @@ -219,7 +230,7 @@ class NodeDriveService {
Future<DriveMessage> _gitClone(int id, Map<String, dynamic> 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);
Expand All @@ -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<DriveMessage> _gitSync(int id, Map<String, dynamic> 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,
);
Expand All @@ -263,6 +282,7 @@ class NodeDriveService {
);
final sync = GitProvider(
endpoint: EndpointId(endpointId),
credentials: gitCredentials,
).synchronizer(drive);
try {
final plan = await sync.plan(
Expand Down
Loading
Loading